@@ -23,6 +23,46 @@ import { RuntimeDecryptionError, WorkflowRuntimeError } from '@workflow/errors';
2323// so consumers can reference it without adding `dom` lib.
2424export type CryptoKey = import ( 'node:crypto' ) . webcrypto . CryptoKey ;
2525
26+ /**
27+ * Raw key material retained alongside keys imported by this module.
28+ *
29+ * Node's synchronous cipher API cannot consume a Web Crypto `CryptoKey`, and
30+ * our keys are deliberately non-extractable. Keeping the original bytes in a
31+ * WeakMap gives the Node replay path access to the same key without making it
32+ * extractable or extending its lifetime beyond the `CryptoKey`. Browser/edge
33+ * callers continue to use Web Crypto and never consult this map.
34+ */
35+ const importedKeyMaterial = new WeakMap < CryptoKey , Uint8Array > ( ) ;
36+
37+ interface NodeDecipher {
38+ setAAD ( data : Uint8Array ) : NodeDecipher ;
39+ setAuthTag ( tag : Uint8Array ) : NodeDecipher ;
40+ update ( data : Uint8Array ) : Uint8Array ;
41+ final ( ) : Uint8Array ;
42+ }
43+
44+ interface NodeCrypto {
45+ createDecipheriv (
46+ algorithm : string ,
47+ key : Uint8Array ,
48+ iv : Uint8Array ,
49+ options ?: { authTagLength ?: number }
50+ ) : NodeDecipher ;
51+ }
52+
53+ /** Resolve node:crypto without a static import, preserving browser bundles. */
54+ function getNodeCrypto ( ) : NodeCrypto | undefined {
55+ try {
56+ return (
57+ globalThis as {
58+ process ?: { getBuiltinModule ?: ( id : string ) => NodeCrypto } ;
59+ }
60+ ) . process ?. getBuiltinModule ?.( 'node:crypto' ) ;
61+ } catch {
62+ return undefined ;
63+ }
64+ }
65+
2666/** AES-GCM nonce length in bytes. */
2767export const NONCE_LENGTH = 12 ;
2868/** AES-GCM authentication tag length in bits. */
@@ -55,7 +95,7 @@ export async function importKey(
5595 `Encryption key must be exactly ${ KEY_LENGTH } bytes, got ${ raw . byteLength } `
5696 ) ;
5797 }
58- return globalThis . crypto . subtle . importKey (
98+ const key = await globalThis . crypto . subtle . importKey (
5999 'raw' ,
60100 raw ,
61101 'AES-GCM' ,
@@ -65,6 +105,82 @@ export async function importKey(
65105 // a strict subset of `KeyUsage[]`, so this cast is sound.
66106 usages as ( 'encrypt' | 'decrypt' ) [ ]
67107 ) ;
108+ // Copy the caller's bytes: a caller may reuse/mutate its input buffer after
109+ // importKey(), while a CryptoKey's material is immutable.
110+ importedKeyMaterial . set ( key , raw . slice ( ) ) ;
111+ return key ;
112+ }
113+
114+ /**
115+ * Decrypt AES-256-GCM synchronously when running on Node and the key was
116+ * imported by this module.
117+ *
118+ * Returns `undefined` when the portable Web Crypto fallback is required (for
119+ * example in a browser, or for an externally-created CryptoKey). Authentication
120+ * failures throw the same RuntimeDecryptionError shape as {@link decrypt}.
121+ */
122+ export function decryptSync (
123+ key : CryptoKey ,
124+ data : Uint8Array ,
125+ aad ?: Uint8Array
126+ ) : Uint8Array | undefined {
127+ const material = importedKeyMaterial . get ( key ) ;
128+ const nodeCrypto = getNodeCrypto ( ) ;
129+ if ( ! material || ! nodeCrypto ) return undefined ;
130+ if ( ! key . usages . includes ( 'decrypt' ) ) {
131+ throw new RuntimeDecryptionError (
132+ 'AES-256-GCM decryption failed: CryptoKey does not support decrypt' ,
133+ {
134+ context : { operation : 'decrypt' , byteLength : data . byteLength } ,
135+ }
136+ ) ;
137+ }
138+
139+ const minLength = NONCE_LENGTH + TAG_BYTES ;
140+ if ( data . byteLength < minLength ) {
141+ throw new RuntimeDecryptionError (
142+ `Encrypted data too short: expected at least ${ minLength } bytes, got ${ data . byteLength } ` ,
143+ {
144+ context : {
145+ operation : 'decrypt' ,
146+ byteLength : data . byteLength ,
147+ } ,
148+ }
149+ ) ;
150+ }
151+
152+ const nonce = data . subarray ( 0 , NONCE_LENGTH ) ;
153+ const ciphertextEnd = data . byteLength - TAG_BYTES ;
154+ const ciphertext = data . subarray ( NONCE_LENGTH , ciphertextEnd ) ;
155+ const authTag = data . subarray ( ciphertextEnd ) ;
156+ try {
157+ const decipher = nodeCrypto . createDecipheriv (
158+ 'aes-256-gcm' ,
159+ material ,
160+ nonce ,
161+ { authTagLength : TAG_BYTES }
162+ ) ;
163+ if ( aad ) decipher . setAAD ( aad ) ;
164+ decipher . setAuthTag ( authTag ) ;
165+ const head = decipher . update ( ciphertext ) ;
166+ const tail = decipher . final ( ) ;
167+ if ( tail . byteLength === 0 ) return head ;
168+ const plaintext = new Uint8Array ( head . byteLength + tail . byteLength ) ;
169+ plaintext . set ( head , 0 ) ;
170+ plaintext . set ( tail , head . byteLength ) ;
171+ return plaintext ;
172+ } catch ( cause ) {
173+ throw new RuntimeDecryptionError (
174+ `AES-256-GCM decryption failed: ${ cause instanceof Error ? cause . message : String ( cause ) } ` ,
175+ {
176+ cause,
177+ context : {
178+ operation : 'decrypt' ,
179+ byteLength : data . byteLength ,
180+ } ,
181+ }
182+ ) ;
183+ }
68184}
69185
70186/**
0 commit comments