-
Notifications
You must be signed in to change notification settings - Fork 262
Add AES-256-GCM encryption primitives and HKDF key derivation #956
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
e9fc5ae
Add browser-compatible AES-GCM to core and HKDF key derivation to wor…
TooTallNate fd74daf
update changeset
TooTallNate c859fec
Move HKDF key derivation server-side: API returns per-run derived key
TooTallNate 68ea9b9
Refactor encrypt/decrypt to accept CryptoKey, export importKey for ca…
TooTallNate 340e752
Pass WorkflowRun entity to getEncryptionKeyForRun where available (av…
TooTallNate 9c32038
.
TooTallNate d0cf65d
Refactor handleSuspension to accept WorkflowRun, pass run entity to g…
TooTallNate 34ef7cc
Overload getEncryptionKeyForRun: accept context for start(), fetch Wo…
TooTallNate 1e3ba29
Split changeset into per-package descriptions for world, world-vercel…
TooTallNate 0e98eef
Remove unnecessary Uint8Array.from() wrapper around Buffer.from()
TooTallNate 00f3a61
Use zod to parse Vercel API response
TooTallNate c28baee
Address peter's comment
TooTallNate File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/world-vercel": patch | ||
| --- | ||
|
|
||
| Implement `getEncryptionKeyForRun` with HKDF-SHA256 per-run key derivation and cross-deployment key resolution via `fetchRunKey` API |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/world": patch | ||
| --- | ||
|
|
||
| Overload `getEncryptionKeyForRun` interface: accept `WorkflowRun` (preferred) or `runId` string with optional opaque world-specific context for `start()` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@workflow/core": patch | ||
| --- | ||
|
|
||
| Add browser-compatible AES-256-GCM encryption module with `importKey`, `encrypt`, and `decrypt` functions; update all runtime callers to resolve `CryptoKey` once per run via `importKey()` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /** | ||
| * Browser-compatible AES-256-GCM encryption module. | ||
| * | ||
| * Uses the Web Crypto API (`globalThis.crypto.subtle`) which works in | ||
| * both modern browsers and Node.js 20+. This module is intentionally | ||
| * free of Node.js-specific imports so it can be bundled for the browser. | ||
| * | ||
| * The World interface (`getEncryptionKeyForRun`) returns a raw 32-byte | ||
| * AES-256 key. Callers should use `importKey()` once to convert it to a | ||
| * `CryptoKey`, then pass that to `encrypt()`/`decrypt()` for all | ||
| * operations within the same run. This avoids repeated `importKey()` | ||
| * calls on every encrypt/decrypt invocation. | ||
| * | ||
| * Wire format: `[nonce (12 bytes)][ciphertext + auth tag]` | ||
| * The `encr` format prefix is NOT part of this module — it's added/stripped | ||
| * by the serialization layer in `maybeEncrypt`/`maybeDecrypt`. | ||
| */ | ||
|
|
||
| // CryptoKey is a global type in browsers and Node.js 20+, but TypeScript's | ||
| // `es2022` lib doesn't include it. Re-export it from the node:crypto types | ||
| // so consumers can reference it without adding `dom` lib. | ||
| export type CryptoKey = import('node:crypto').webcrypto.CryptoKey; | ||
|
|
||
| const NONCE_LENGTH = 12; | ||
| const TAG_LENGTH = 128; // bits | ||
| const KEY_LENGTH = 32; // bytes (AES-256) | ||
|
|
||
| /** | ||
| * Import a raw AES-256 key as a `CryptoKey` for use with `encrypt()`/`decrypt()`. | ||
| * | ||
| * Callers should call this once per run (after `getEncryptionKeyForRun()`) | ||
| * and pass the resulting `CryptoKey` to all subsequent encrypt/decrypt calls. | ||
| * | ||
| * @param raw - Raw 32-byte AES-256 key (from World.getEncryptionKeyForRun) | ||
| * @returns CryptoKey ready for AES-GCM operations | ||
| */ | ||
| export async function importKey(raw: Uint8Array) { | ||
| if (raw.byteLength !== KEY_LENGTH) { | ||
| throw new Error( | ||
| `Encryption key must be exactly ${KEY_LENGTH} bytes, got ${raw.byteLength}` | ||
| ); | ||
| } | ||
| return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, [ | ||
| 'encrypt', | ||
| 'decrypt', | ||
| ]); | ||
| } | ||
|
|
||
| /** | ||
| * Encrypt data using AES-256-GCM. | ||
| * | ||
| * @param key - CryptoKey from `importKey()` | ||
| * @param data - Plaintext to encrypt | ||
| * @returns `[nonce (12 bytes)][ciphertext + GCM auth tag]` | ||
| */ | ||
| export async function encrypt( | ||
| key: CryptoKey, | ||
| data: Uint8Array | ||
| ): Promise<Uint8Array> { | ||
| const nonce = globalThis.crypto.getRandomValues(new Uint8Array(NONCE_LENGTH)); | ||
| const ciphertext = await globalThis.crypto.subtle.encrypt( | ||
| { name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH }, | ||
| key, | ||
| data | ||
| ); | ||
| const result = new Uint8Array(NONCE_LENGTH + ciphertext.byteLength); | ||
| result.set(nonce, 0); | ||
| result.set(new Uint8Array(ciphertext), NONCE_LENGTH); | ||
| return result; | ||
| } | ||
|
|
||
| /** | ||
| * Decrypt data using AES-256-GCM. | ||
| * | ||
| * @param key - CryptoKey from `importKey()` | ||
| * @param data - `[nonce (12 bytes)][ciphertext + GCM auth tag]` | ||
| * @returns Decrypted plaintext | ||
| */ | ||
| export async function decrypt( | ||
| key: CryptoKey, | ||
| data: Uint8Array | ||
| ): Promise<Uint8Array> { | ||
| const minLength = NONCE_LENGTH + TAG_LENGTH / 8; // nonce + auth tag | ||
| if (data.byteLength < minLength) { | ||
| throw new Error( | ||
| `Encrypted data too short: expected at least ${minLength} bytes, got ${data.byteLength}` | ||
| ); | ||
| } | ||
| const nonce = data.subarray(0, NONCE_LENGTH); | ||
| const ciphertext = data.subarray(NONCE_LENGTH); | ||
| const plaintext = await globalThis.crypto.subtle.decrypt( | ||
| { name: 'AES-GCM', iv: nonce, tagLength: TAG_LENGTH }, | ||
| key, | ||
| ciphertext | ||
| ); | ||
| return new Uint8Array(plaintext); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,9 @@ import { | |
| isLegacySpecVersion, | ||
| SPEC_VERSION_CURRENT, | ||
| type WorkflowInvokePayload, | ||
| type WorkflowRun, | ||
| } from '@workflow/world'; | ||
| import { type CryptoKey, importKey } from '../encryption.js'; | ||
| import { | ||
| dehydrateStepReturnValue, | ||
| hydrateStepArguments, | ||
|
|
@@ -18,22 +20,27 @@ import { getWorkflowQueueName } from './helpers.js'; | |
| import { getWorld } from './world.js'; | ||
|
|
||
| /** | ||
| * Internal helper that returns both the hook and the resolved encryption key. | ||
| * Internal helper that returns the hook, the associated workflow run, | ||
| * and the resolved encryption key. | ||
| */ | ||
| async function getHookByTokenWithKey( | ||
| token: string | ||
| ): Promise<{ hook: Hook; encryptionKey: Uint8Array | undefined }> { | ||
| async function getHookByTokenWithKey(token: string): Promise<{ | ||
| hook: Hook; | ||
| run: WorkflowRun; | ||
| encryptionKey: CryptoKey | undefined; | ||
| }> { | ||
| const world = getWorld(); | ||
| const hook = await world.hooks.getByToken(token); | ||
| const encryptionKey = await world.getEncryptionKeyForRun?.(hook.runId); | ||
| const run = await world.runs.get(hook.runId); | ||
| const rawKey = await world.getEncryptionKeyForRun?.(run); | ||
| const encryptionKey = rawKey ? await importKey(rawKey) : undefined; | ||
| if (typeof hook.metadata !== 'undefined') { | ||
| hook.metadata = await hydrateStepArguments( | ||
| hook.metadata as any, | ||
| hook.runId, | ||
| encryptionKey | ||
| ); | ||
| } | ||
| return { hook, encryptionKey }; | ||
| return { hook, run, encryptionKey }; | ||
| } | ||
|
|
||
| /** | ||
|
|
@@ -80,24 +87,30 @@ export async function getHookByToken(token: string): Promise<Hook> { | |
| export async function resumeHook<T = any>( | ||
| tokenOrHook: string | Hook, | ||
| payload: T, | ||
| encryptionKeyOverride?: Uint8Array | undefined | ||
| encryptionKeyOverride?: CryptoKey | ||
| ): Promise<Hook> { | ||
| return await waitedUntil(() => { | ||
| return trace('hook.resume', async (span) => { | ||
| const world = getWorld(); | ||
|
|
||
| try { | ||
| let hook: Hook; | ||
| let encryptionKey: Uint8Array | undefined; | ||
| let workflowRun: WorkflowRun; | ||
| let encryptionKey: CryptoKey | undefined; | ||
| if (typeof tokenOrHook === 'string') { | ||
| const result = await getHookByTokenWithKey(tokenOrHook); | ||
| hook = result.hook; | ||
| workflowRun = result.run; | ||
| encryptionKey = encryptionKeyOverride ?? result.encryptionKey; | ||
| } else { | ||
| hook = tokenOrHook; | ||
| encryptionKey = | ||
| encryptionKeyOverride ?? | ||
| (await world.getEncryptionKeyForRun?.(hook.runId)); | ||
| workflowRun = await world.runs.get(hook.runId); | ||
| if (encryptionKeyOverride) { | ||
| encryptionKey = encryptionKeyOverride; | ||
| } else { | ||
| const rawKey = await world.getEncryptionKeyForRun?.(workflowRun); | ||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For cross-deployment hook resumptions / fetching of the encryption key, we should consider enriching the "source" of the request for the key (i.e. |
||
| encryptionKey = rawKey ? await importKey(rawKey) : undefined; | ||
| } | ||
| } | ||
|
|
||
| span?.setAttributes({ | ||
|
|
@@ -138,8 +151,6 @@ export async function resumeHook<T = any>( | |
| { v1Compat } | ||
| ); | ||
|
|
||
| const workflowRun = await world.runs.get(hook.runId); | ||
|
|
||
| span?.setAttributes({ | ||
| ...Attribute.WorkflowName(workflowRun.workflowName), | ||
| }); | ||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.