11/**
2- * Browser-compatible AES-256-GCM encryption module .
2+ * Browser-compatible encryption helpers .
33 *
44 * Uses the Web Crypto API (`globalThis.crypto.subtle`) which works in
55 * both modern browsers and Node.js 20+. This module is intentionally
66 * free of Node.js-specific imports so it can be bundled for the browser.
77 *
88 * The World interface (`getEncryptionKeyForRun`) returns a raw 32-byte
9- * AES-256 key. Callers should use `importKey()` once to convert it to a
10- * `CryptoKey`, then pass that to `encrypt()`/`decrypt()` for all
11- * operations within the same run. This avoids repeated `importKey()`
12- * calls on every encrypt/decrypt invocation.
9+ * per-run key. Core imports that key twice:
10+ * - as `AES-GCM` for legacy `encr` payloads
11+ * - as `HKDF` for derived-key `enc2` payloads
1312 *
14- * Wire format: `[nonce (12 bytes)][ciphertext + auth tag]`
15- * The `encr` format prefix is NOT part of this module — it's added/stripped
16- * by the serialization layer in `maybeEncrypt`/`maybeDecrypt` .
13+ * Wire format for AES-GCM blobs : `[nonce (12 bytes)][ciphertext + auth tag]`
14+ * The `encr` / `enc2` serialization prefixes are NOT part of this module —
15+ * they're added/stripped by the serialization layer.
1716 */
1817
1918// CryptoKey is a global type in browsers and Node.js 20+, but TypeScript's
@@ -24,42 +23,258 @@ export type CryptoKey = import('node:crypto').webcrypto.CryptoKey;
2423const NONCE_LENGTH = 12 ;
2524const TAG_LENGTH = 128 ; // bits
2625const KEY_LENGTH = 32 ; // bytes (AES-256)
26+ const HKDF_SALT = new Uint8Array ( KEY_LENGTH ) ;
27+ const V2_HEADER_LENGTH_SIZE = 4 ;
28+ const MAX_V2_HEADER_LENGTH = 16 * 1024 ;
29+
30+ const encoder = new TextEncoder ( ) ;
31+ const decoder = new TextDecoder ( ) ;
32+
33+ export interface EncryptionKeyBundle {
34+ legacyKey : CryptoKey ;
35+ derivationKey : CryptoKey ;
36+ }
37+
38+ export type EncryptionKeyLike = CryptoKey | EncryptionKeyBundle ;
39+
40+ export interface EncryptedV2Header {
41+ purpose : string ;
42+ runId : string ;
43+ activityId ?: string ;
44+ counter ?: number ;
45+ }
46+
47+ function validateRawKey ( raw : Uint8Array ) : void {
48+ if ( raw . byteLength !== KEY_LENGTH ) {
49+ throw new Error (
50+ `Encryption key must be exactly ${ KEY_LENGTH } bytes, got ${ raw . byteLength } `
51+ ) ;
52+ }
53+ }
54+
55+ export function isEncryptionKeyBundle (
56+ key : EncryptionKeyLike | undefined
57+ ) : key is EncryptionKeyBundle {
58+ return (
59+ typeof key === 'object' &&
60+ key !== null &&
61+ 'legacyKey' in key &&
62+ 'derivationKey' in key
63+ ) ;
64+ }
65+
66+ export function getLegacyKey (
67+ key : EncryptionKeyLike | undefined
68+ ) : CryptoKey | undefined {
69+ if ( ! key ) return undefined ;
70+ return isEncryptionKeyBundle ( key ) ? key . legacyKey : key ;
71+ }
72+
73+ export function getDerivationKey (
74+ key : EncryptionKeyLike | undefined
75+ ) : CryptoKey | undefined {
76+ if ( ! isEncryptionKeyBundle ( key ) ) return undefined ;
77+ return key . derivationKey ;
78+ }
2779
2880/**
29- * Import a raw AES-256 key as a `CryptoKey` for use with `encrypt()`/`decrypt() `.
81+ * Import a raw AES-256 key as a legacy `AES-GCM` `CryptoKey `.
3082 *
31- * Callers should call this once per run (after `getEncryptionKeyForRun()`)
32- * and pass the resulting `CryptoKey` to all subsequent encrypt/decrypt calls .
83+ * This is kept for backwards compatibility with callers that still
84+ * explicitly work with the legacy `encr` path .
3385 *
3486 * @param raw - Raw 32-byte AES-256 key (from World.getEncryptionKeyForRun)
3587 * @returns CryptoKey ready for AES-GCM operations
3688 */
37- export async function importKey ( raw : Uint8Array ) {
38- if ( raw . byteLength !== KEY_LENGTH ) {
39- throw new Error (
40- `Encryption key must be exactly ${ KEY_LENGTH } bytes, got ${ raw . byteLength } `
41- ) ;
42- }
89+ export async function importLegacyKey ( raw : Uint8Array ) {
90+ validateRawKey ( raw ) ;
4391 return globalThis . crypto . subtle . importKey ( 'raw' , raw , 'AES-GCM' , false , [
4492 'encrypt' ,
4593 'decrypt' ,
4694 ] ) ;
4795}
4896
97+ /**
98+ * Import a raw per-run key as an `HKDF` base key for derived `enc2` keys.
99+ *
100+ * @param raw - Raw 32-byte per-run key
101+ * @returns CryptoKey ready for HKDF deriveKey operations
102+ */
103+ export async function importDerivationKey ( raw : Uint8Array ) {
104+ validateRawKey ( raw ) ;
105+ return globalThis . crypto . subtle . importKey ( 'raw' , raw , 'HKDF' , false , [
106+ 'deriveKey' ,
107+ ] ) ;
108+ }
109+
110+ /**
111+ * Import a raw per-run key into the pair of keys needed by core.
112+ */
113+ export async function importEncryptionKeys (
114+ raw : Uint8Array
115+ ) : Promise < EncryptionKeyBundle > {
116+ const [ legacyKey , derivationKey ] = await Promise . all ( [
117+ importLegacyKey ( raw ) ,
118+ importDerivationKey ( raw ) ,
119+ ] ) ;
120+ return { legacyKey, derivationKey } ;
121+ }
122+
123+ /**
124+ * Derive an activity-specific AES-256-GCM key from the per-run HKDF key.
125+ *
126+ * The `info` bytes should encode the full activity context. For `enc2`
127+ * payloads we reuse the exact serialized header bytes as both the HKDF
128+ * `info` parameter and the AES-GCM AAD.
129+ */
130+ export async function deriveActivityKey (
131+ derivationKey : CryptoKey ,
132+ info : Uint8Array
133+ ) : Promise < CryptoKey > {
134+ const derived = await globalThis . crypto . subtle . deriveKey (
135+ {
136+ name : 'HKDF' ,
137+ hash : 'SHA-256' ,
138+ salt : HKDF_SALT ,
139+ info,
140+ } ,
141+ derivationKey ,
142+ { name : 'AES-GCM' , length : KEY_LENGTH * 8 } ,
143+ false ,
144+ [ 'encrypt' , 'decrypt' ]
145+ ) ;
146+ return derived as CryptoKey ;
147+ }
148+
149+ function isValidEncryptedV2Header ( value : unknown ) : value is EncryptedV2Header {
150+ if ( value === null || typeof value !== 'object' || Array . isArray ( value ) ) {
151+ return false ;
152+ }
153+
154+ const header = value as Record < string , unknown > ;
155+ return (
156+ typeof header . purpose === 'string' &&
157+ header . purpose . length > 0 &&
158+ typeof header . runId === 'string' &&
159+ header . runId . length > 0 &&
160+ ( header . activityId === undefined ||
161+ typeof header . activityId === 'string' ) &&
162+ ( header . counter === undefined ||
163+ ( typeof header . counter === 'number' &&
164+ Number . isInteger ( header . counter ) &&
165+ header . counter >= 0 ) )
166+ ) ;
167+ }
168+
169+ export function encodeEncryptedV2Header ( header : EncryptedV2Header ) : Uint8Array {
170+ if ( ! isValidEncryptedV2Header ( header ) ) {
171+ throw new Error ( 'Invalid enc2 header' ) ;
172+ }
173+
174+ const normalized : EncryptedV2Header = {
175+ purpose : header . purpose ,
176+ runId : header . runId ,
177+ } ;
178+ if ( header . activityId !== undefined ) {
179+ normalized . activityId = header . activityId ;
180+ }
181+ if ( header . counter !== undefined ) {
182+ normalized . counter = header . counter ;
183+ }
184+
185+ return encoder . encode ( JSON . stringify ( normalized ) ) ;
186+ }
187+
188+ export function decodeEncryptedV2Header (
189+ headerBytes : Uint8Array
190+ ) : EncryptedV2Header {
191+ let parsed : unknown ;
192+ try {
193+ parsed = JSON . parse ( decoder . decode ( headerBytes ) ) ;
194+ } catch ( error ) {
195+ throw new Error ( 'Invalid enc2 header JSON' , { cause : error } ) ;
196+ }
197+
198+ if ( ! isValidEncryptedV2Header ( parsed ) ) {
199+ throw new Error ( 'Invalid enc2 header fields' ) ;
200+ }
201+
202+ return parsed ;
203+ }
204+
205+ /**
206+ * Encode the inner `enc2` payload body:
207+ * `[4-byte header length][header bytes][nonce + ciphertext + auth tag]`
208+ */
209+ export function encodeEncryptedV2Body (
210+ headerBytes : Uint8Array ,
211+ ciphertext : Uint8Array
212+ ) : Uint8Array {
213+ const result = new Uint8Array (
214+ V2_HEADER_LENGTH_SIZE + headerBytes . byteLength + ciphertext . byteLength
215+ ) ;
216+ new DataView ( result . buffer ) . setUint32 ( 0 , headerBytes . byteLength , false ) ;
217+ result . set ( headerBytes , V2_HEADER_LENGTH_SIZE ) ;
218+ result . set ( ciphertext , V2_HEADER_LENGTH_SIZE + headerBytes . byteLength ) ;
219+ return result ;
220+ }
221+
222+ export function decodeEncryptedV2Body ( data : Uint8Array ) : {
223+ headerBytes : Uint8Array ;
224+ ciphertext : Uint8Array ;
225+ } {
226+ if ( data . byteLength < V2_HEADER_LENGTH_SIZE ) {
227+ throw new Error ( 'enc2 payload too short to contain header length' ) ;
228+ }
229+
230+ const headerLength = new DataView (
231+ data . buffer ,
232+ data . byteOffset ,
233+ data . byteLength
234+ ) . getUint32 ( 0 , false ) ;
235+
236+ if ( headerLength === 0 || headerLength > MAX_V2_HEADER_LENGTH ) {
237+ throw new Error ( `Invalid enc2 header length: ${ headerLength } ` ) ;
238+ }
239+
240+ const headerStart = V2_HEADER_LENGTH_SIZE ;
241+ const headerEnd = headerStart + headerLength ;
242+ if ( data . byteLength < headerEnd ) {
243+ throw new Error ( 'enc2 payload truncated before header completed' ) ;
244+ }
245+
246+ const ciphertext = data . subarray ( headerEnd ) ;
247+ if ( ciphertext . byteLength === 0 ) {
248+ throw new Error ( 'enc2 payload missing ciphertext' ) ;
249+ }
250+
251+ return {
252+ headerBytes : data . subarray ( headerStart , headerEnd ) ,
253+ ciphertext,
254+ } ;
255+ }
256+
49257/**
50258 * Encrypt data using AES-256-GCM.
51259 *
52- * @param key - CryptoKey from `importKey ()`
260+ * @param key - CryptoKey from `importLegacyKey ()`
53261 * @param data - Plaintext to encrypt
262+ * @param aad - Optional AES-GCM additional authenticated data
54263 * @returns `[nonce (12 bytes)][ciphertext + GCM auth tag]`
55264 */
56265export async function encrypt (
57266 key : CryptoKey ,
58- data : Uint8Array
267+ data : Uint8Array ,
268+ aad ?: Uint8Array
59269) : Promise < Uint8Array > {
60270 const nonce = globalThis . crypto . getRandomValues ( new Uint8Array ( NONCE_LENGTH ) ) ;
61271 const ciphertext = await globalThis . crypto . subtle . encrypt (
62- { name : 'AES-GCM' , iv : nonce , tagLength : TAG_LENGTH } ,
272+ {
273+ name : 'AES-GCM' ,
274+ iv : nonce ,
275+ tagLength : TAG_LENGTH ,
276+ ...( aad ? { additionalData : aad } : { } ) ,
277+ } ,
63278 key ,
64279 data
65280 ) ;
@@ -72,13 +287,15 @@ export async function encrypt(
72287/**
73288 * Decrypt data using AES-256-GCM.
74289 *
75- * @param key - CryptoKey from `importKey ()`
290+ * @param key - CryptoKey from `importLegacyKey ()`
76291 * @param data - `[nonce (12 bytes)][ciphertext + GCM auth tag]`
292+ * @param aad - Optional AES-GCM additional authenticated data
77293 * @returns Decrypted plaintext
78294 */
79295export async function decrypt (
80296 key : CryptoKey ,
81- data : Uint8Array
297+ data : Uint8Array ,
298+ aad ?: Uint8Array
82299) : Promise < Uint8Array > {
83300 const minLength = NONCE_LENGTH + TAG_LENGTH / 8 ; // nonce + auth tag
84301 if ( data . byteLength < minLength ) {
@@ -89,7 +306,12 @@ export async function decrypt(
89306 const nonce = data . subarray ( 0 , NONCE_LENGTH ) ;
90307 const ciphertext = data . subarray ( NONCE_LENGTH ) ;
91308 const plaintext = await globalThis . crypto . subtle . decrypt (
92- { name : 'AES-GCM' , iv : nonce , tagLength : TAG_LENGTH } ,
309+ {
310+ name : 'AES-GCM' ,
311+ iv : nonce ,
312+ tagLength : TAG_LENGTH ,
313+ ...( aad ? { additionalData : aad } : { } ) ,
314+ } ,
93315 key ,
94316 ciphertext
95317 ) ;
0 commit comments