Skip to content

Commit 85e262c

Browse files
committed
review: narrow the o11y decrypt key type and dedupe an import
- `hydrateDataWithKey` accepted `PayloadKey`, which includes `SealTarget`. A seal target holds only a public key, so it can open neither scheme — passing one compiled fine and then always failed at runtime. Added a `DecryptionKey` alias (`CryptoKey | RunPayloadKeys`) and narrowed the signature, so that misuse is now a compile error. A `@ts-expect-error` test pins the guarantee. - `hydrateResourceIOAsync` dynamically imported `@workflow/core/serialization-format` twice. Destructure both bindings from the single existing import instead.
1 parent bf7b0aa commit 85e262c

4 files changed

Lines changed: 55 additions & 9 deletions

File tree

‎packages/core/src/serialization-format.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,6 +711,37 @@ describe('encrypted data handling', () => {
711711
expect(result).toEqual(original);
712712
});
713713

714+
it('rejects a write-only seal target at the type level', async () => {
715+
// A seal target holds only a public key, so it can open neither scheme.
716+
// Accepting it here would compile into a guaranteed runtime failure, so
717+
// the signature takes a read capability instead.
718+
const { sealTo, deriveRunPayloadKeys } = await import(
719+
'./serialization/encryption.js'
720+
);
721+
const target = sealTo(new Uint8Array(32).fill(1));
722+
723+
// @ts-expect-error - SealTarget is write-only and must not be accepted
724+
void hydrateDataWithKey(
725+
makeSealedPayload(),
726+
observabilityRevivers,
727+
target
728+
);
729+
730+
// Both read capabilities remain accepted: the run's full key bundle...
731+
const keys = await deriveRunPayloadKeys(testKeyRaw);
732+
await expect(
733+
hydrateDataWithKey(makeSealedPayload(), observabilityRevivers, keys)
734+
).rejects.toThrow(); // fake ciphertext, but it type-checks and is attempted
735+
// ...and a bare symmetric key (which simply cannot open sealed data).
736+
await expect(
737+
hydrateDataWithKey(
738+
makeSealedPayload(),
739+
observabilityRevivers,
740+
await getTestKey()
741+
)
742+
).resolves.toBeInstanceOf(Uint8Array);
743+
});
744+
714745
it('should pass sealed payloads through untouched even when a key is present', async () => {
715746
// A sealed payload is encrypted to the run's X25519 public key, so the
716747
// symmetric key this function receives cannot open it. Attempting an

‎packages/core/src/serialization-format.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,17 @@ import { parse, unflatten } from 'devalue';
2323
* `encryption.ts` and `sealed-box.ts` are all free of Node dependencies.
2424
*/
2525
export {
26+
type DecryptionKey,
2627
decrypt as decryptEnvelope,
2728
deriveRunPayloadKeys,
2829
encrypt as encryptEnvelope,
2930
isRunPayloadKeys,
3031
isSealTarget,
3132
type PayloadKey,
32-
runPayloadKeys,
3333
type RunPayloadKeys,
34-
sealTo,
34+
runPayloadKeys,
3535
type SealTarget,
36+
sealTo,
3637
} from './serialization/encryption.js';
3738

3839
// ---------------------------------------------------------------------------
@@ -419,11 +420,13 @@ export function hydrateData(value: unknown, revivers: Revivers): unknown {
419420
* @param key - The run's key material. Pass `RunPayloadKeys` (from
420421
* `deriveRunPayloadKeys`) to open both symmetric (`encr`) and sealed
421422
* (`encp`) payloads; a bare `CryptoKey` opens only the symmetric ones.
423+
* Typed as a read capability so a write-only seal target — which could open
424+
* neither scheme — is rejected at compile time rather than failing here.
422425
*/
423426
export async function hydrateDataWithKey(
424427
value: unknown,
425428
revivers: Revivers,
426-
key: import('./serialization/encryption.js').PayloadKey | undefined
429+
key: import('./serialization/encryption.js').DecryptionKey | undefined
427430
): Promise<unknown> {
428431
let data = value;
429432
if (data instanceof Uint8Array && isEncryptedData(data) && key) {

‎packages/core/src/serialization/encryption.ts‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,17 @@ export interface RunPayloadKeys {
9797
*/
9898
export type PayloadKey = CryptoKey | SealTarget | RunPayloadKeys;
9999

100+
/**
101+
* The subset of {@link PayloadKey} that can actually *read* a payload.
102+
*
103+
* Excludes {@link SealTarget}, which is write-only by construction: it holds a
104+
* public key, so it can open neither `encp` (needs the private scalar) nor
105+
* `encr` (needs the symmetric key). Decrypt-side signatures should take this
106+
* rather than `PayloadKey`, so passing a seal target is a compile error instead
107+
* of a guaranteed runtime failure.
108+
*/
109+
export type DecryptionKey = CryptoKey | RunPayloadKeys;
110+
100111
/**
101112
* Build a write-only seal capability for a recipient run's public key.
102113
*

‎packages/web-shared/src/lib/hydration.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ export async function hydrateResourceIOAsync<T>(
487487
resource: T,
488488
key?: Uint8Array
489489
): Promise<T> {
490-
const { hydrateDataWithKey } = await import(
490+
const { hydrateDataWithKey, deriveRunPayloadKeys } = await import(
491491
'@workflow/core/serialization-format'
492492
);
493493
// Payloads may be zstd-compressed (the Web DecompressionStream has no zstd);
@@ -502,11 +502,12 @@ export async function hydrateResourceIOAsync<T>(
502502
// it (a cross-deployment hook resumption, say), and opening those needs the
503503
// run's X25519 scalar in addition to its AES key. Both are derived from the
504504
// same 32 bytes the key-retrieval endpoint returns.
505-
const cryptoKey = key
506-
? await import('@workflow/core/serialization-format').then(
507-
({ deriveRunPayloadKeys }) => deriveRunPayloadKeys(key)
508-
)
509-
: undefined;
505+
// Resolve the *full* key capability, not just the symmetric key: a run's
506+
// event log can contain sealed ('encp') payloads that another run wrote to
507+
// it (a cross-deployment hook resumption, say), and opening those needs the
508+
// run's X25519 scalar in addition to its AES key. Both derive from the same
509+
// 32 bytes the key-retrieval endpoint returns.
510+
const cryptoKey = key ? await deriveRunPayloadKeys(key) : undefined;
510511
const revivers = getRevivers();
511512

512513
async function hydrateField(value: unknown): Promise<unknown> {

0 commit comments

Comments
 (0)