Skip to content

Commit 64ce19d

Browse files
authored
feat: server function encrypted bound args (#421)
## Summary Server-emitted bound captures of server functions — the closure variables of inline `"use server"` functions and the arguments passed to a server-side `.bind(...)` — used to travel plaintext on the wire as part of the `$h` outlined chunk's `bound` array. A malicious client could submit a legitimate action token paired with attacker-chosen bound values, swapping a captured `userId=42` for `userId=99` and updating a different user's data while authenticated as someone else. Classic IDOR / bound-arg tampering. This PR bundles every action's bound capture array into the same AES-256-GCM token that already protects action identity. Token plaintext becomes `[actionId, boundBytesAsBase64]`, where the bound bytes come from `@lazarv/rsc`'s sync flight encoder. Bound values never travel plaintext on the wire, and any tampering — at the token, the action id, or the bound payload — invalidates the GCM auth tag and the call is rejected before the action runs. ## Why an AEAD primitive instead of a separate HMAC The first attempt sat an HMAC tag alongside the plaintext bound on the wire, then bound `(id, bound, sig)` together at verification time. That approach had a fundamental architectural flaw: by the time the user clicks a bound action, `callServer` packages the *bound prefix as positional args* (not as a `$h` reference) so it lands in the call body indistinguishably from runtime args. There is no `$h` chunk in the call body to attach a sig to, and the dispatcher cannot tell which of the positional args were "bound" vs "user-supplied". Tampering would have been undetectable in the dominant code path. Encoding the bound array inside the encrypted action token closes this by removing the bound prefix from the wire entirely. The client sends only runtime args; the server recovers bound by decrypting the token and prepends it before invoking the action. There is nothing for an attacker to tamper with. ## Type fidelity is the load-bearing detail A naive implementation would `JSON.stringify` the bound array into the token plaintext. That silently strips type information from `Date`, `BigInt`, `Map`, `Set`, `RegExp`, `URL`, `URLSearchParams`, typed arrays — every typed value the wire format already supports through `decodeReply`. After a round-trip, the action would receive a `string` where it expected a `Date`, etc. Bound captures are now routed through `syncToBuffer` / `syncFromBuffer`, the existing public sync flight serialization pair on `@lazarv/rsc`. Bound captures travel through the same `$<tag>` scheme that `decodeReply` already speaks for client-supplied args, so any typed value the framework supports anywhere else also survives bound-capture round-trip with full fidelity. ## Implementation `packages/react-server/server/action-crypto.mjs` gains `encryptActionToken(actionId, bound)` and `decryptActionToken(token)`. The encrypt path runs `bound` through `syncToBuffer` to get a `Uint8Array`, base64-encodes it, and embeds it as the second element of the JSON plaintext `[actionId, boundBytesAsBase64 | null]`. The decrypt path inverts that: parse JSON, decode base64, run `syncFromBuffer` to recover the typed array. `encryptActionId` becomes a thin delegator over `encryptActionToken(id, null)` so existing callers keep working with the unified plaintext format. `decryptActionId` delegates to `decryptActionToken` and returns just the action id. A small fallback in `parseTokenPlaintext` accepts pre-upgrade plain-string plaintexts as `{ actionId, bound: null }` so tokens issued before this change are still valid during a rolling deploy. `packages/react-server/server/action-register.mjs` updates `createServerRefBind` so the cached `$$id` getter returns `encryptActionToken(fullId, accumulatedBound)` rather than `encryptActionId(fullId)`. The bound array is plaintext on the function (needed for `Function.prototype.bind` invocation and for progressive-enhancement form rendering) but only the encrypted token form goes onto the wire. The unbound `registerServerReference` path still uses `encryptActionId`, which now produces a token whose plaintext is `[fullId, null]` — same shape, no special case at decrypt time. `packages/react-server/server/render-rsc.jsx` does three things. It exposes a `resolveServerReference` on the runtime's `moduleResolver` that returns `{ id: ref.$$id, bound: null }` for every server reference, so the flight serializer skips its plaintext-bound fallback. The header-based action dispatcher and the progressive-enhancement form-field dispatcher both call `decryptActionToken` instead of `decryptActionId`, recover any token-encoded bound, and prepend it to the runtime args before invoking the action. The `decodeReply` wrapper passes a `decryptServerReferenceId` hook into `@lazarv/rsc` so the callback-arg case (a bound server reference passed as a value to *another* server function call) decrypts the inner token and prepends its bound at bind time. `packages/rsc/server/shared.mjs` and `packages/rsc/server/reply-decoder.mjs` add the host-supplied hooks. The flight serializer now honors `metadata.bound` from `resolveServerReference` when explicitly provided, falling back to `value.$$bound` only when the resolver doesn't speak. The reply decoder accepts a `decryptServerReferenceId` option that, when present, transforms the `$h` chunk's id into `{ actionId, bound }`; the recovered bound is prepended to any wire-supplied bound array before binding. Both branches stay no-op by default — `@lazarv/rsc` itself has no opinion about token formats and remains runtime-agnostic. ## Migration Backward compatible. The encryption key resolution chain (`serverFunctions.secret` / `secretFile`, env vars, `previousSecrets` / `previousSecretFiles`) is unchanged and covers both action identity and bound captures under one key. Tokens issued by an older runtime version that's still serving traffic during a rolling deploy decode cleanly via the legacy plain-string fallback in `parseTokenPlaintext`. There are no new configuration flags and no transitional period to manage. ## Tests `test/__test__/action-crypto.spec.mjs` covers token roundtrip across primitive and structured bound values, tamper detection (single-byte flip, truncation, non-base64), key rotation (sign under previous, decrypt under primary or rotation), legacy plain-string plaintext compatibility, `encryptActionId` / `decryptActionId` thin-wrapper semantics, and a full typed-value matrix asserting `Date`, `BigInt`, `Map`, `Set`, `RegExp`, `URL`, `URLSearchParams`, and typed arrays each survive the encrypt/decrypt round-trip with both `instanceof` and value equality. A nested-mix case asserts that typed values inside structured bound (a `Date` inside an object inside an array, a `Map<string, Object[]>`, etc.) all round-trip together. `packages/rsc/__tests__/flight-bound-args-integrity.test.mjs` covers the protocol layer: that a resolver returning `bound: null` overrides `$$bound` and emits no plaintext bound on the wire, that the unbound case carries `bound: null` end-to-end, that consumers without a resolver still get the legacy serialization (back-compat for plain `@lazarv/rsc` users), and that the `$h` decoder hook is invoked on token-encoded ids in the callback-arg case and prepends recovered bound to any wire-supplied bound. The existing `test/__test__/use-inline.spec.mjs` ("use server inline with captured variables") exercises the full pipeline — page render → flight stream → client decode → callServer → decrypt → dispatch — with closures capturing render-time data. It is the load-bearing E2E for this change and continues to pass without modification. ## Docs `docs/src/pages/en/(pages)/guide/server-functions.mdx` and the Japanese mirror gain a Security section covering action identity and bound captures, key resolution order, key rotation pattern, semantics of client-side `.bind()` extensions (treated as runtime args, not as new captures), and the one known limitation: bound captures whose values are `File` or `Blob` carry the slot reference in the token but not the binary content, which is rare in practice but worth flagging. The Japanese file also gets `<Link name>` anchors that match the EN convention and a closing fence for a previously dangling code block.
1 parent 96c56e7 commit 64ce19d

9 files changed

Lines changed: 1191 additions & 72 deletions

File tree

docs/src/pages/en/(pages)/guide/server-functions.mdx

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,4 +340,92 @@ export default function TodoApp() {
340340
}
341341
```
342342

343-
The file is treated as a client component because of the top-level `"use client"` directive, but the `addItem` function is extracted into a separate server module. This works the same way as defining the server function in a standalone `"use server"` file — the framework handles the extraction automatically.
343+
The file is treated as a client component because of the top-level `"use client"` directive, but the `addItem` function is extracted into a separate server module. This works the same way as defining the server function in a standalone `"use server"` file — the runtime handles the extraction automatically.
344+
345+
<Link name="security">
346+
## Security
347+
</Link>
348+
349+
Server function calls travel a round-trip across the network: the runtime emits a reference to the function, the client invokes it, and the runtime resolves and runs the function on the server. Two things need to be tamper-evident across that round-trip — the *identity* of the action being called, and any *captured values* that travel with it.
350+
351+
<Link name="action-identity-and-bound-captures">
352+
### Action identity and bound captures
353+
</Link>
354+
355+
Every server function reference is encoded as a single AES-256-GCM token. The token's plaintext is the pair `(actionId, bound)`, where `bound` is the array of values that were captured by `.bind(...)` or by an inline closure at render time, or `null` for unbound actions. The ciphertext is what the client sees on the wire, and what it sends back when the action is invoked.
356+
357+
```jsx
358+
function ProfilePage({ userId }) {
359+
return (
360+
<form
361+
action={async (formData) => {
362+
"use server";
363+
await db.users.update(userId, formData.get("name"));
364+
}}
365+
>
366+
367+
</form>
368+
);
369+
}
370+
```
371+
372+
In the example above, `userId` is captured by the inline server function. The runtime emits a token that bundles both the action's identity and the captured `userId` together. The client never sees `userId` in plaintext, never round-trips it as a separate value, and cannot edit it without invalidating the token's authentication tag — which causes the call to be rejected.
373+
374+
This applies to every form of server function:
375+
376+
- Module-scope `"use server"` functions (no captures → bound is `null`)
377+
- Inline closures with render-time captures
378+
- Server-side `.bind(...)` usage to partially apply a server function
379+
- Bound server references passed as arguments to other server functions
380+
381+
<Link name="security-configuration">
382+
### Configuration
383+
</Link>
384+
385+
There is one configuration property to set: a stable encryption secret. Without it, the runtime generates an ephemeral key per process — fine for development, but tokens won't survive a restart or be valid across multiple instances of the server.
386+
387+
```js
388+
// react-server.config.mjs
389+
export default {
390+
serverFunctions: {
391+
secret: process.env.ACTION_SECRET, // 32-byte hex, or any string (hashed to 32 bytes)
392+
},
393+
};
394+
```
395+
396+
The key resolves in this order:
397+
398+
1. `REACT_SERVER_FUNCTIONS_SECRET` environment variable
399+
2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` environment variable (path to a file)
400+
3. `serverFunctions.secret` in the runtime config
401+
4. `serverFunctions.secretFile` in the runtime config (path to a file)
402+
5. A random ephemeral key (development fallback only)
403+
404+
<Link name="key-rotation">
405+
### Key rotation
406+
</Link>
407+
408+
To rotate without invalidating in-flight tokens, list the prior keys under `serverFunctions.previousSecrets` (or `serverFunctions.previousSecretFiles`). Incoming tokens are tried against the primary key first and then each previous key in turn:
409+
410+
```js
411+
export default {
412+
serverFunctions: {
413+
secret: process.env.ACTION_SECRET,
414+
previousSecrets: [process.env.ACTION_SECRET_PREVIOUS],
415+
},
416+
};
417+
```
418+
419+
The same rotation applies to both action identity and bound captures — they share one key.
420+
421+
<Link name="client-side-bind">
422+
### Client-side `.bind()`
423+
</Link>
424+
425+
When a bound server function is exposed to a client component and the client calls `.bind(...)` to add more arguments, those additional arguments are treated as *runtime arguments*, not as new captures. They travel as ordinary call args (alongside whatever the user passes at invocation), and they are not included inside the encrypted token. This is intentional: only the original server-emitted bound is integrity-protected. Client-added arguments are effectively just the arguments the client chooses to send at call time.
426+
427+
<Link name="security-limitations">
428+
### Limitations
429+
</Link>
430+
431+
The token covers the values in the bound array. If a captured value is a `File` or `Blob`, the token covers the slot reference that points to the binary content, but not the binary content itself. Servers that bind server-constructed binary data into a closure should be aware that an attacker controlling the upload could substitute the binary content even with a valid token. In practice, captured `File`/`Blob` values are rare in server function closures.

docs/src/pages/ja/(pages)/guide/server-functions.mdx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,4 +199,93 @@ export default function App() {
199199
<MyClientComponent action={action} />
200200
</div>
201201
);
202-
}
202+
}
203+
```
204+
205+
<Link name="security">
206+
## セキュリティ
207+
</Link>
208+
209+
サーバ関数の呼び出しはネットワーク越しのラウンドトリップを経由します。ランタイムは関数への参照を発行し、クライアントがそれを呼び出し、ランタイムがその関数をサーバ側で解決して実行します。このラウンドトリップにおいて改ざん検知可能であるべきものが二つあります。呼び出される対象アクションの **ID** と、それに伴って渡される **キャプチャされた値** です。
210+
211+
<Link name="action-identity-and-bound-captures">
212+
### アクション ID とバインドされたキャプチャ
213+
</Link>
214+
215+
すべてのサーバ関数の参照は、単一の AES-256-GCM トークンとしてエンコードされます。このトークンの平文は `(actionId, bound)` のペアであり、`bound` はレンダリング時に `.bind(...)` またはインラインクロージャによってキャプチャされた値の配列、もしくは引数を持たないアクションの場合は `null` です。クライアントが目にするのは暗号文のみで、アクションの呼び出し時にもこの暗号文がそのまま送り返されます。
216+
217+
```jsx
218+
function ProfilePage({ userId }) {
219+
return (
220+
<form
221+
action={async (formData) => {
222+
"use server";
223+
await db.users.update(userId, formData.get("name"));
224+
}}
225+
>
226+
227+
</form>
228+
);
229+
}
230+
```
231+
232+
上記の例では、`userId` がインラインのサーバ関数によってキャプチャされています。ランタイムは、アクションの ID とキャプチャされた `userId` の両方を一つのトークンに束ねて発行します。クライアントは `userId` を平文で目にすることはなく、別の値としてラウンドトリップさせることもできず、トークンの認証タグを無効化せずに編集することはできません — 認証タグが壊れた場合、その呼び出しは拒否されます。
233+
234+
これはサーバ関数のあらゆる形式に適用されます:
235+
236+
- モジュールスコープの `"use server"` 関数 (キャプチャなし → bound は `null`)
237+
- レンダリング時にキャプチャを行うインラインクロージャ
238+
- サーバ関数を部分適用するためのサーバ側 `.bind(...)` の利用
239+
- 他のサーバ関数の引数として渡されるバインド済みのサーバ関数参照
240+
241+
<Link name="security-configuration">
242+
### 設定
243+
</Link>
244+
245+
設定すべきプロパティは一つだけです — 永続化された暗号化シークレットです。これがない場合、ランタイムはプロセスごとに一時的な鍵を生成します。開発時には問題ありませんが、サーバの再起動を跨ぐトークンや、複数インスタンス間で有効なトークンは得られません。
246+
247+
```js
248+
// react-server.config.mjs
249+
export default {
250+
serverFunctions: {
251+
secret: process.env.ACTION_SECRET, // 32 バイトの hex 文字列、または任意の文字列 (32 バイトにハッシュされます)
252+
},
253+
};
254+
```
255+
256+
鍵は次の順序で解決されます:
257+
258+
1. `REACT_SERVER_FUNCTIONS_SECRET` 環境変数
259+
2. `REACT_SERVER_FUNCTIONS_SECRET_FILE` 環境変数 (ファイルへのパス)
260+
3. ランタイム設定の `serverFunctions.secret`
261+
4. ランタイム設定の `serverFunctions.secretFile` (ファイルへのパス)
262+
5. ランダムな一時的な鍵 (開発時のフォールバックのみ)
263+
264+
<Link name="key-rotation">
265+
### 鍵のローテーション
266+
</Link>
267+
268+
処理中のトークンを失効させずに鍵をローテーションするには、以前の鍵を `serverFunctions.previousSecrets` (またはファイルの場合は `serverFunctions.previousSecretFiles`) に列挙します。受信したトークンはまず主鍵で検証され、その後、各以前の鍵が順に試されます:
269+
270+
```js
271+
export default {
272+
serverFunctions: {
273+
secret: process.env.ACTION_SECRET,
274+
previousSecrets: [process.env.ACTION_SECRET_PREVIOUS],
275+
},
276+
};
277+
```
278+
279+
同じローテーションがアクション ID とバインドされたキャプチャの両方に適用されます — 鍵は一つだけです。
280+
281+
<Link name="client-side-bind">
282+
### クライアントサイドの `.bind()`
283+
</Link>
284+
285+
バインド済みのサーバ関数がクライアントコンポーネントに渡され、クライアントがさらに `.bind(...)` を呼んで引数を追加した場合、これらの追加引数は新しいキャプチャではなく **ランタイム引数** として扱われます。これらは通常の呼び出し引数 (ユーザが呼び出し時に渡すものと同様) として送られ、暗号化されたトークンには含まれません。これは意図的な仕様です — サーバから発行されたバインドのみが整合性で保護され、クライアントが追加した引数は実質的にクライアントが呼び出し時に送ることを選んだ値に過ぎません。
286+
287+
<Link name="security-limitations">
288+
### 制限事項
289+
</Link>
290+
291+
トークンはバインド配列内の値を保護します。キャプチャされた値が `File``Blob` の場合、トークンはバイナリコンテンツへのスロット参照を保護しますが、バイナリコンテンツそのものは保護しません。サーバ側で構築したバイナリデータをクロージャにバインドするサーバ関数は、有効なトークンであっても、アップロードを制御する攻撃者によってバイナリコンテンツが差し替えられる可能性があることに注意してください。実際にはサーバ関数のクロージャでキャプチャされる `File` / `Blob` はまれです。

packages/react-server/server/action-crypto.mjs

Lines changed: 144 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import {
66
} from "node:crypto";
77
import { readFile } from "node:fs/promises";
88

9+
import { syncToBuffer } from "@lazarv/rsc/server";
10+
import { syncFromBuffer } from "@lazarv/rsc/client";
11+
912
let resolvedKey = null;
1013
let previousKeys = [];
1114

@@ -193,21 +196,56 @@ function getPreviousKeys() {
193196
}
194197

195198
/**
196-
* Encrypt a server function ID using AES-256-GCM with a random IV.
199+
* Encrypt an action token (id + optional bound capture array) using AES-256-GCM
200+
* with a random IV.
201+
*
202+
* Bound captures travel inside the encrypted blob using `@lazarv/rsc`'s
203+
* `syncToBuffer` — the same wire format `decodeReply` speaks — so typed
204+
* values (`Date`, `BigInt`, `Map`, `Set`, `RegExp`, `URL`, `URLSearchParams`,
205+
* typed arrays, …) survive the round-trip with full fidelity. A naive
206+
* `JSON.stringify` on `bound` would silently lose those types.
207+
*
208+
* Bundling `(actionId, boundBytes)` into a single AEAD-protected token gives
209+
* us tamper-evident bound captures for free: the same primitive that binds
210+
* action identity also binds the captured arguments. Bound values never
211+
* travel plaintext on the wire, and a malicious client cannot edit them
212+
* without invalidating the auth tag.
213+
*
214+
* Each call produces a unique ciphertext (random IV), so every render emits
215+
* fresh tokens even for the same `(actionId, bound)` pair.
197216
*
198-
* Each call produces a unique token because the IV is randomly generated.
199-
* This means every render produces fresh, unique action tokens.
217+
* Plaintext layout (post-decrypt):
200218
*
201-
* @param {string} actionId - The original action ID (e.g. "src/actions#submitForm")
219+
* `[actionId, boundBytesAsBase64 | null]`
220+
*
221+
* - `null` → unbound action.
222+
* - base64 string → decode to bytes, then `syncFromBuffer` to recover the
223+
* typed bound array.
224+
*
225+
* @param {string} actionId - The original action ID (e.g. "src/actions#submit")
226+
* @param {Array<unknown> | null | undefined} [bound] - Captured bound args, or null/undefined for unbound
202227
* @returns {string} base64url-encoded encrypted token
203228
*/
204-
export function encryptActionId(actionId) {
229+
export function encryptActionToken(actionId, bound) {
205230
const key = getKey();
206231
const iv = randomBytes(12);
207232

233+
// Serialize the bound array via @lazarv/rsc's sync flight encoder so
234+
// typed values survive the round-trip. Returns null when the action
235+
// has no bound captures (or an empty array — same wire result either way).
236+
let boundEncoded = null;
237+
if (Array.isArray(bound) && bound.length > 0) {
238+
const buffer = syncToBuffer(bound);
239+
boundEncoded = Buffer.from(buffer).toString("base64");
240+
}
241+
242+
// Plaintext is JSON [actionId, base64Bytes | null]. Array (not object)
243+
// form keeps the shape stable and avoids JSON-key-ordering ambiguity.
244+
const plaintext = JSON.stringify([actionId, boundEncoded]);
245+
208246
const cipher = createCipheriv("aes-256-gcm", key, iv);
209247
const encrypted = Buffer.concat([
210-
cipher.update(actionId, "utf8"),
248+
cipher.update(plaintext, "utf8"),
211249
cipher.final(),
212250
]);
213251
const authTag = cipher.getAuthTag();
@@ -216,6 +254,19 @@ export function encryptActionId(actionId) {
216254
return Buffer.concat([iv, authTag, encrypted]).toString("base64url");
217255
}
218256

257+
/**
258+
* Encrypt a server function ID (no bound captures). Thin wrapper over
259+
* `encryptActionToken(actionId, null)` kept for callers that don't carry
260+
* bound state. Emits the same array-form plaintext, so a token produced
261+
* here decrypts cleanly via either `decryptActionId` or `decryptActionToken`.
262+
*
263+
* @param {string} actionId
264+
* @returns {string} base64url-encoded encrypted token
265+
*/
266+
export function encryptActionId(actionId) {
267+
return encryptActionToken(actionId, null);
268+
}
269+
219270
/**
220271
* Try to decrypt a token with a specific key.
221272
*
@@ -248,28 +299,105 @@ function tryDecryptWithKey(token, key) {
248299
}
249300

250301
/**
251-
* Decrypt an encrypted action token back to the original action ID.
302+
* Parse a decrypted plaintext back into `{ actionId, bound }`.
303+
*
304+
* Handles two formats:
252305
*
253-
* Tries the primary key first, then falls back to previous keys (rotation).
306+
* - **New (array)**: `[actionId, boundBase64 | null]` — emitted by every
307+
* token issued since type-preserving bound landed. `boundBase64` is
308+
* either `null` (unbound) or a base64-encoded `syncToBuffer` blob that
309+
* decodes to the typed bound array via `syncFromBuffer`.
310+
* - **Legacy (plain string)**: just the action id, no bound. Tokens that
311+
* pre-date this change (e.g. still in flight from a pre-upgrade render)
312+
* decrypt cleanly to `{ actionId, bound: null }`.
313+
*
314+
* Returns `null` on any structural inconsistency, including a base64 blob
315+
* that doesn't survive `syncFromBuffer` (corruption, version skew).
316+
*
317+
* @param {string} plaintext
318+
* @returns {{actionId: string, bound: Array<unknown> | null} | null}
319+
*/
320+
function parseTokenPlaintext(plaintext) {
321+
// Legacy: action id as a plain string. Any token that wasn't
322+
// JSON.stringified as an array starts with a non-bracket character.
323+
if (plaintext.length > 0 && plaintext[0] !== "[") {
324+
return { actionId: plaintext, bound: null };
325+
}
326+
let parsed;
327+
try {
328+
parsed = JSON.parse(plaintext);
329+
} catch {
330+
return null;
331+
}
332+
if (
333+
!Array.isArray(parsed) ||
334+
parsed.length !== 2 ||
335+
typeof parsed[0] !== "string"
336+
) {
337+
return null;
338+
}
339+
const actionId = parsed[0];
340+
const boundEncoded = parsed[1];
341+
342+
if (boundEncoded === null) {
343+
return { actionId, bound: null };
344+
}
345+
346+
if (typeof boundEncoded !== "string") return null;
347+
348+
// Decode the bound bytes via @lazarv/rsc's sync flight decoder. This
349+
// recovers typed values (Date, BigInt, Map, Set, RegExp, URL,
350+
// URLSearchParams, typed arrays, …) with full fidelity — the same
351+
// contract decodeReply gives us for client-supplied args.
352+
let bound;
353+
try {
354+
const bytes = Buffer.from(boundEncoded, "base64");
355+
bound = syncFromBuffer(bytes);
356+
} catch {
357+
return null;
358+
}
359+
if (!Array.isArray(bound)) return null;
360+
361+
return { actionId, bound };
362+
}
363+
364+
/**
365+
* Decrypt an action token back to its full `{ actionId, bound }` payload.
366+
*
367+
* Tries the primary key first, then any rotation keys. Returns `null` if
368+
* decryption fails (wrong key, tampered ciphertext, malformed plaintext).
254369
*
255370
* @param {string} token - base64url-encoded encrypted token
256-
* @returns {string | null} The original action ID, or null if decryption fails
371+
* @returns {{actionId: string, bound: Array<unknown> | null} | null}
257372
*/
258-
export function decryptActionId(token) {
373+
export function decryptActionToken(token) {
259374
if (!token || typeof token !== "string") return null;
260375

261-
const key = getKey();
262-
263376
// Try primary key, then previous keys for rotation.
264-
const keysToTry = [key, ...getPreviousKeys()];
377+
const keysToTry = [getKey(), ...getPreviousKeys()];
265378
for (const k of keysToTry) {
266-
const result = tryDecryptWithKey(token, k);
267-
if (result !== null) return result;
379+
const plaintext = tryDecryptWithKey(token, k);
380+
if (plaintext !== null) {
381+
return parseTokenPlaintext(plaintext);
382+
}
268383
}
269-
270384
return null;
271385
}
272386

387+
/**
388+
* Decrypt an action token to just the action ID (drops any bound payload).
389+
*
390+
* Convenience for callers that only need the action identity — registry
391+
* lookups, logging, etc. Returns `null` on failure.
392+
*
393+
* @param {string} token
394+
* @returns {string | null}
395+
*/
396+
export function decryptActionId(token) {
397+
const result = decryptActionToken(token);
398+
return result ? result.actionId : null;
399+
}
400+
273401
/**
274402
* Wrap a server reference map (Proxy or static object) with a layer that
275403
* transparently handles encrypted action ID lookups.

0 commit comments

Comments
 (0)