@@ -15,6 +15,7 @@ import { clearSecret, digestHex, sharedPool, toBase64, toHex } from "./memory";
1515import { getCryptoTestVectors } from "./testing" ;
1616import { createCommitment } from "./commitment" ;
1717import { recordCryptoTelemetry , type CryptoResultCode } from "./telemetry" ;
18+ import { canonicalizeAttachmentDescriptors } from "./attachment-metadata" ;
1819
1920export interface EnvelopeAttachment {
2021 filename : string ;
@@ -30,6 +31,8 @@ export interface EncryptionMetadata {
3031 nonce : string ;
3132 mac : string ;
3233 ephemeral_public_key ?: string ;
34+ recipient_key_id ?: string ;
35+ sender_key_id ?: string ;
3336}
3437
3538export interface EnvelopePayload {
@@ -60,6 +63,8 @@ export interface SealEnvelopeInput {
6063 } > ;
6164 /** When aborted, all internal references are released and the promise rejects. */
6265 signal ?: AbortSignal ;
66+ recipientKeyId ?: string ;
67+ senderKeyId ?: string ;
6368}
6469
6570const GCM_TAG_BYTES = 16 ;
@@ -126,6 +131,57 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
126131 "decrypt" ,
127132 ] ) ;
128133
134+ // --- Pre-process attachments to get descriptors for AAD ---
135+ const attachmentsToProcess = input . attachments ?? [ ] ;
136+ const descriptors : Array < {
137+ filename : string ;
138+ content_type : string ;
139+ size_bytes : number ;
140+ content_hash : string ;
141+ } > = [ ] ;
142+ const preparedAttachments : Array < {
143+ filename : string ;
144+ content_type : string ;
145+ size_bytes : number ;
146+ data ?: ArrayBuffer ;
147+ content_hash : string ;
148+ } > = [ ] ;
149+
150+ for ( const attachment of attachmentsToProcess ) {
151+ let hash : string ;
152+ if ( attachment . data ) {
153+ // View into caller's ArrayBuffer — no copy for hashing.
154+ const dataBytes = new Uint8Array ( attachment . data ) ;
155+ hash = await digestHex ( dataBytes ) ;
156+ if ( attachment . content_hash && hash !== attachment . content_hash ) {
157+ throw new Error (
158+ `Mismatch between supplied bytes and content_hash for attachment ${ attachment . filename } ` ,
159+ ) ;
160+ }
161+ } else if ( attachment . content_hash ) {
162+ hash = attachment . content_hash ;
163+ } else {
164+ throw new Error (
165+ `Attachment ${ attachment . filename } must include either data bytes or a validated content_hash` ,
166+ ) ;
167+ }
168+ descriptors . push ( {
169+ filename : attachment . filename ,
170+ content_type : attachment . content_type ,
171+ size_bytes : attachment . size_bytes ,
172+ content_hash : hash ,
173+ } ) ;
174+ preparedAttachments . push ( {
175+ filename : attachment . filename ,
176+ content_type : attachment . content_type ,
177+ size_bytes : attachment . size_bytes ,
178+ data : attachment . data ,
179+ content_hash : hash ,
180+ } ) ;
181+ }
182+
183+ const aad = canonicalizeAttachmentDescriptors ( descriptors ) ;
184+
129185 // --- Body encryption ---
130186 throwIfAborted ( ) ;
131187 const ivBuf = sharedPool . acquire ( 12 ) ;
@@ -142,7 +198,7 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
142198 // a pool buffer, but we manage the result lifecycle explicitly below.
143199 const ciphertext = new Uint8Array (
144200 await crypto . subtle . encrypt (
145- { name : "AES-GCM" , iv : iv as BufferSource } ,
201+ { name : "AES-GCM" , iv : iv as BufferSource , additionalData : aad as BufferSource } ,
146202 key ,
147203 plaintext as BufferSource ,
148204 ) ,
@@ -157,22 +213,13 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
157213 // --- Attachments (sequential, buffers freed per iteration) ---
158214 throwIfAborted ( ) ;
159215 const attachments : EnvelopeAttachment [ ] = [ ] ;
160- for ( const attachment of input . attachments ?? [ ] ) {
216+ for ( const attachment of preparedAttachments ) {
161217 throwIfAborted ( ) ;
162- let hash : string ;
163218 let encMetadata : EncryptionMetadata | undefined ;
164219 let ciphertextStr : string | undefined ;
165220
166221 if ( attachment . data ) {
167- // View into caller's ArrayBuffer — no copy for hashing.
168222 const dataBytes = new Uint8Array ( attachment . data ) ;
169- hash = await digestHex ( dataBytes ) ;
170- if ( attachment . content_hash && hash !== attachment . content_hash ) {
171- throw new Error (
172- `Mismatch between supplied bytes and content_hash for attachment ${ attachment . filename } ` ,
173- ) ;
174- }
175-
176223 const attIv = sharedPool . acquire ( 12 ) ;
177224 const attIvView = new Uint8Array ( attIv , 0 , 12 ) ;
178225 crypto . getRandomValues ( attIvView ) ;
@@ -196,18 +243,12 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
196243 // Release attachment crypto buffers.
197244 clearSecret ( attCiphertext ) ;
198245 sharedPool . release ( attIv ) ;
199- } else if ( attachment . content_hash ) {
200- hash = attachment . content_hash ;
201- } else {
202- throw new Error (
203- `Attachment ${ attachment . filename } must include either data bytes or a validated content_hash` ,
204- ) ;
205246 }
206247 attachments . push ( {
207248 filename : attachment . filename ,
208249 content_type : attachment . content_type ,
209250 size_bytes : attachment . size_bytes ,
210- content_hash : hash ,
251+ content_hash : attachment . content_hash ,
211252 ...( encMetadata ? { encryption_metadata : encMetadata } : { } ) ,
212253 ...( ciphertextStr ? { ciphertext : ciphertextStr } : { } ) ,
213254 } ) ;
@@ -218,11 +259,14 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
218259
219260 // Compute the content commitment BEFORE base64-encoding so the binary
220261 // ciphertext can be released immediately after.
221- const contentCommitment = await digestHex ( ciphertext ) ;
262+ const contentCommitment = await createCommitment ( ciphertext ) ;
222263
223264 // Encode the ciphertext — the binary buffer is no longer needed afterwards.
224265 const ciphertextBase64 = toBase64 ( ciphertext ) ;
225266
267+ const nonceHex = toHex ( iv ) ;
268+ const macHex = toHex ( tag ) ;
269+
226270 // Release body ciphertext buffer now that both commitment and base64 are done.
227271 clearSecret ( ciphertext ) ;
228272 sharedPool . release ( ivBuf ) ;
@@ -234,8 +278,10 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
234278 timestamp : now ? now ( ) . toISOString ( ) : new Date ( ) . toISOString ( ) ,
235279 encryption_metadata : {
236280 algorithm : "AES-256-GCM" ,
237- nonce : toHex ( iv ) ,
238- mac : toHex ( tag ) ,
281+ nonce : nonceHex ,
282+ mac : macHex ,
283+ ...( input . recipientKeyId ? { recipient_key_id : input . recipientKeyId } : { } ) ,
284+ ...( input . senderKeyId ? { sender_key_id : input . senderKeyId } : { } ) ,
239285 } ,
240286 content_commitment : contentCommitment ,
241287 attachments,
0 commit comments