Skip to content

Commit 5843448

Browse files
committed
feat: authenticate attachment descriptors as protected metadata
1 parent 2987ee0 commit 5843448

4 files changed

Lines changed: 286 additions & 37 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { canonicalize } from "./jcs";
2+
3+
export interface AttachmentDescriptor {
4+
filename: string;
5+
content_type: string;
6+
size_bytes: number;
7+
content_hash: string;
8+
}
9+
10+
/**
11+
* Canonicalizes attachment descriptors for additional authenticated data (AAD).
12+
* Uses RFC 8785 JSON Canonicalization Scheme (JCS) under the hood.
13+
* Authenticates filename, MIME type, size, ordering, and content commitment.
14+
*/
15+
export function canonicalizeAttachmentDescriptors(attachments: AttachmentDescriptor[]): Uint8Array {
16+
const normalized = attachments.map((a) => ({
17+
filename: a.filename,
18+
content_type: a.content_type,
19+
size_bytes: a.size_bytes,
20+
content_hash: a.content_hash,
21+
}));
22+
const canonicalString = canonicalize(normalized);
23+
return new TextEncoder().encode(canonicalString);
24+
}
25+
26+
/**
27+
* Safe filename normalization for display purposes.
28+
* Strips directory traversal segments and unsafe characters.
29+
* This is handled before display, not inside the cryptographic identity.
30+
*/
31+
export function sanitizeFilenameForDisplay(filename: string): string {
32+
// Replace backslashes with forward slashes
33+
let safe = filename.replace(/\\/g, "/");
34+
35+
// Remove directory traversal sequences (../ or ./)
36+
safe = safe.replace(/(?:\.\.\/|\.\/)/g, "");
37+
38+
// Strip leading slashes
39+
safe = safe.replace(/^\/+/, "");
40+
41+
// Extract the base filename segment
42+
const lastSegment = safe.split("/").pop();
43+
return lastSegment || "unnamed_attachment";
44+
}

src/services/crypto/envelope.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { clearSecret, digestHex, sharedPool, toBase64, toHex } from "./memory";
1515
import { getCryptoTestVectors } from "./testing";
1616
import { createCommitment } from "./commitment";
1717
import { recordCryptoTelemetry, type CryptoResultCode } from "./telemetry";
18+
import { canonicalizeAttachmentDescriptors } from "./attachment-metadata";
1819

1920
export 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
});

src/services/crypto/open-envelope.ts

Lines changed: 26 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import { verifyCommitment } from "./commitment";
1717
import { recordCryptoTelemetry, type CryptoResultCode } from "./telemetry";
18+
import { canonicalizeAttachmentDescriptors } from "./attachment-metadata";
1819

1920
/** Minimal non-secret error carrying a stable code (no key/plaintext leakage). */
2021
export class OpenEnvelopeError extends Error {
@@ -213,6 +214,25 @@ export async function openEnvelope(
213214
throw new OpenEnvelopeError("recipient key unavailable", "crypto_decryption_error");
214215
}
215216

217+
const parsedAttachments = Array.isArray(payload.attachments)
218+
? payload.attachments.map((a) => ({
219+
filename: str((a as { filename?: unknown }).filename, "attachment.filename"),
220+
content_type: str(
221+
(a as { content_type?: unknown }).content_type,
222+
"attachment.content_type",
223+
),
224+
size_bytes: Number(
225+
str((a as { size_bytes?: unknown }).size_bytes, "attachment.size_bytes"),
226+
),
227+
content_hash: str(
228+
(a as { content_hash?: unknown }).content_hash,
229+
"attachment.content_hash",
230+
),
231+
}))
232+
: [];
233+
234+
const aad = canonicalizeAttachmentDescriptors(parsedAttachments);
235+
216236
const iv = fromHex(nonceHex);
217237
const ivCopy = new Uint8Array(new ArrayBuffer(iv.length));
218238
ivCopy.set(iv);
@@ -223,7 +243,11 @@ export async function openEnvelope(
223243
// fails closed on tamper or wrong key).
224244
let decrypted: ArrayBuffer;
225245
try {
226-
decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv: ivCopy }, key, ctCopy);
246+
decrypted = await crypto.subtle.decrypt(
247+
{ name: "AES-GCM", iv: ivCopy, additionalData: aad as BufferSource },
248+
key,
249+
ctCopy,
250+
);
227251
} catch {
228252
throw new OpenEnvelopeError(
229253
"decryption failed (wrong key or tampered)",
@@ -233,29 +257,12 @@ export async function openEnvelope(
233257

234258
const body = new TextDecoder().decode(new Uint8Array(decrypted));
235259

236-
const attachments = Array.isArray(payload.attachments)
237-
? payload.attachments.map((a) => ({
238-
filename: str((a as { filename?: unknown }).filename, "attachment.filename"),
239-
content_type: str(
240-
(a as { content_type?: unknown }).content_type,
241-
"attachment.content_type",
242-
),
243-
size_bytes: Number(
244-
str((a as { size_bytes?: unknown }).size_bytes, "attachment.size_bytes"),
245-
),
246-
content_hash: str(
247-
(a as { content_hash?: unknown }).content_hash,
248-
"attachment.content_hash",
249-
),
250-
}))
251-
: [];
252-
253260
return {
254261
sender,
255262
recipient,
256263
timestamp,
257264
body,
258-
attachments,
265+
attachments: parsedAttachments,
259266
recipientKeyId,
260267
senderKeyId,
261268
};

0 commit comments

Comments
 (0)