@@ -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 ;
@@ -130,6 +131,57 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
130131 "decrypt" ,
131132 ] ) ;
132133
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+
133185 // --- Body encryption ---
134186 throwIfAborted ( ) ;
135187 const ivBuf = sharedPool . acquire ( 12 ) ;
@@ -146,7 +198,7 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
146198 // a pool buffer, but we manage the result lifecycle explicitly below.
147199 const ciphertext = new Uint8Array (
148200 await crypto . subtle . encrypt (
149- { name : "AES-GCM" , iv : iv as BufferSource } ,
201+ { name : "AES-GCM" , iv : iv as BufferSource , additionalData : aad as BufferSource } ,
150202 key ,
151203 plaintext as BufferSource ,
152204 ) ,
@@ -161,22 +213,13 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
161213 // --- Attachments (sequential, buffers freed per iteration) ---
162214 throwIfAborted ( ) ;
163215 const attachments : EnvelopeAttachment [ ] = [ ] ;
164- for ( const attachment of input . attachments ?? [ ] ) {
216+ for ( const attachment of preparedAttachments ) {
165217 throwIfAborted ( ) ;
166- let hash : string ;
167218 let encMetadata : EncryptionMetadata | undefined ;
168219 let ciphertextStr : string | undefined ;
169220
170221 if ( attachment . data ) {
171- // View into caller's ArrayBuffer — no copy for hashing.
172222 const dataBytes = new Uint8Array ( attachment . data ) ;
173- hash = await digestHex ( dataBytes ) ;
174- if ( attachment . content_hash && hash !== attachment . content_hash ) {
175- throw new Error (
176- `Mismatch between supplied bytes and content_hash for attachment ${ attachment . filename } ` ,
177- ) ;
178- }
179-
180223 const attIv = sharedPool . acquire ( 12 ) ;
181224 const attIvView = new Uint8Array ( attIv , 0 , 12 ) ;
182225 crypto . getRandomValues ( attIvView ) ;
@@ -200,18 +243,12 @@ export async function sealEnvelope(input: SealEnvelopeInput): Promise<SealedEnve
200243 // Release attachment crypto buffers.
201244 clearSecret ( attCiphertext ) ;
202245 sharedPool . release ( attIv ) ;
203- } else if ( attachment . content_hash ) {
204- hash = attachment . content_hash ;
205- } else {
206- throw new Error (
207- `Attachment ${ attachment . filename } must include either data bytes or a validated content_hash` ,
208- ) ;
209246 }
210247 attachments . push ( {
211248 filename : attachment . filename ,
212249 content_type : attachment . content_type ,
213250 size_bytes : attachment . size_bytes ,
214- content_hash : hash ,
251+ content_hash : attachment . content_hash ,
215252 ...( encMetadata ? { encryption_metadata : encMetadata } : { } ) ,
216253 ...( ciphertextStr ? { ciphertext : ciphertextStr } : { } ) ,
217254 } ) ;
0 commit comments