Skip to content

Commit 009b357

Browse files
authored
Merge pull request Stellar-Mail#1811 from Killerjunior/feature/authenticate-attachment-metadata
Feature/authenticate attachment metadata
2 parents 257d656 + 34b65d1 commit 009b357

9 files changed

Lines changed: 827 additions & 108 deletions

File tree

package-lock.json

Lines changed: 75 additions & 62 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
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: 67 additions & 21 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;
@@ -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

3538
export 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

6570
const 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,

src/services/crypto/key-id.ts

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/**
2+
* Cryptographic key identifiers and rotation metadata.
3+
*
4+
* Implements deterministic derivation of collision-resistant key identifiers (Key IDs)
5+
* and defines versioned key metadata for tracking rotation and revocation states.
6+
*/
7+
8+
import { CryptoError } from "./errors";
9+
10+
/**
11+
* Key metadata tracking version, creation/expiry timestamps, and rotation state.
12+
*/
13+
export interface KeyMetadata {
14+
/** Deterministic, collision-resistant identifier derived from the public key. */
15+
keyId: string;
16+
/** Cryptographic algorithm name (e.g. "Ed25519", "AES-256-GCM"). */
17+
algorithm: string;
18+
/** Version of the key, incremented upon rotation. */
19+
version: number;
20+
/** ISO 8601 timestamp representing the time of key creation/issuance. */
21+
createdAt: string;
22+
/** Optional ISO 8601 timestamp representing key expiration. */
23+
expiresAt?: string;
24+
/** Current state of the key in its lifecycle. */
25+
rotationState: "active" | "rotated" | "revoked";
26+
}
27+
28+
/**
29+
* Policy defining which keys are eligible for decryption.
30+
*/
31+
export interface KeyRotationPolicy {
32+
/** If false, only "active" keys can be used for decryption. */
33+
allowRotatedKeys: boolean;
34+
/** Optional grace period in seconds after expiration during which a key remains decryptable. */
35+
gracePeriodSeconds?: number;
36+
/** If true, expired keys can still decrypt envelopes (ignoring expiresAt). */
37+
allowExpiredKeysForDecryption?: boolean;
38+
}
39+
40+
/**
41+
* Derives a deterministic, collision-resistant key identifier from a public key.
42+
*
43+
* Scheme:
44+
* 1. Compute SHA-256 hash of the public key bytes.
45+
* 2. Format the hash as a lowercase hex string.
46+
* 3. Prefix with "kid_".
47+
*
48+
* @param publicKey The public key bytes (non-secret).
49+
* @returns A promise resolving to the key identifier.
50+
*/
51+
export async function deriveKeyId(publicKey: Uint8Array): Promise<string> {
52+
if (!publicKey || publicKey.length === 0) {
53+
throw new CryptoError("crypto_key_error", "Public key bytes cannot be empty");
54+
}
55+
56+
const hashBuffer = await crypto.subtle.digest("SHA-256", publicKey as BufferSource);
57+
const hashArray = Array.from(new Uint8Array(hashBuffer));
58+
const hex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
59+
return `kid_${hex}`;
60+
}
61+
62+
/**
63+
* Validates whether a key can be used for decryption according to the rotation policy.
64+
*
65+
* @param key The key metadata to evaluate.
66+
* @param policy The rotation policy to enforce.
67+
* @param referenceTime The reference time to check expiration against (defaults to now).
68+
* @returns True if the key is decryptable, false otherwise.
69+
*/
70+
export function isKeyDecryptable(
71+
key: KeyMetadata,
72+
policy: KeyRotationPolicy,
73+
referenceTime: Date = new Date(),
74+
): boolean {
75+
if (key.rotationState === "revoked") {
76+
return false;
77+
}
78+
79+
if (key.rotationState === "rotated" && !policy.allowRotatedKeys) {
80+
return false;
81+
}
82+
83+
if (key.expiresAt && !policy.allowExpiredKeysForDecryption) {
84+
const expiresTime = Date.parse(key.expiresAt);
85+
if (Number.isNaN(expiresTime)) {
86+
return false; // Safely fail closed on malformed dates.
87+
}
88+
const refMs = referenceTime.getTime();
89+
if (refMs > expiresTime) {
90+
if (policy.gracePeriodSeconds !== undefined) {
91+
const graceMs = policy.gracePeriodSeconds * 1000;
92+
if (refMs > expiresTime + graceMs) {
93+
return false;
94+
}
95+
} else {
96+
return false;
97+
}
98+
}
99+
}
100+
101+
return true;
102+
}
103+
104+
/**
105+
* Resolves all eligible keys matching a given Key ID from a set of keys,
106+
* enforcing the rotation policy.
107+
*
108+
* @param keyId The target key identifier.
109+
* @param keys A pool of available keys.
110+
* @param policy The rotation policy to filter by.
111+
* @param referenceTime The reference time to check expiration against.
112+
* @returns A filtered list of decryptable keys matching the Key ID.
113+
*/
114+
export function resolveKeysFromSet(
115+
keyId: string,
116+
keys: KeyMetadata[],
117+
policy: KeyRotationPolicy,
118+
referenceTime: Date = new Date(),
119+
): KeyMetadata[] {
120+
return keys.filter((k) => k.keyId === keyId && isKeyDecryptable(k, policy, referenceTime));
121+
}
122+
123+
/**
124+
* Sorts and selects the best/primary key from a candidate list (e.g. in case of collision).
125+
*
126+
* Precedence rules:
127+
* 1. "active" keys take precedence over "rotated" keys.
128+
* 2. Higher version number takes precedence.
129+
* 3. Newer creation time takes precedence.
130+
*
131+
* @param keys Candidates matching the criteria.
132+
* @returns The best matching key, or undefined if the list is empty.
133+
*/
134+
export function selectBestKey(keys: KeyMetadata[]): KeyMetadata | undefined {
135+
if (keys.length === 0) {
136+
return undefined;
137+
}
138+
139+
return [...keys].sort((a, b) => {
140+
// 1. Rotation state (active first)
141+
if (a.rotationState === "active" && b.rotationState !== "active") return -1;
142+
if (a.rotationState !== "active" && b.rotationState === "active") return 1;
143+
144+
// 2. Version (higher first)
145+
if (b.version !== a.version) {
146+
return b.version - a.version;
147+
}
148+
149+
// 3. Created time (newer first)
150+
const timeA = Date.parse(a.createdAt);
151+
const timeB = Date.parse(b.createdAt);
152+
const validA = !Number.isNaN(timeA);
153+
const validB = !Number.isNaN(timeB);
154+
155+
if (validA && validB) {
156+
return timeB - timeA;
157+
}
158+
if (validA) return -1;
159+
if (validB) return 1;
160+
161+
return 0;
162+
})[0];
163+
}

0 commit comments

Comments
 (0)