Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions src/services/crypto/__tests__/content-key.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { generateContentKey } from "../content-key";

describe("Content Key Security - Non-extractable default (#1691)", () => {
it("should generate content-encryption keys as non-extractable by default", async () => {
const key = await generateContentKey();

expect(key).toBeDefined();
expect(key.extractable).toBe(false);
expect(key.usages).toEqual(expect.arrayContaining(["encrypt", "decrypt"]));
});

it("should fail when attempting to export a default non-extractable key", async () => {
const key = await generateContentKey();

// Exporting non-extractable key should reject in Web Crypto API
await expect(crypto.subtle.exportKey("raw", key)).rejects.toThrow();
});

it("should allow encrypt and decrypt operations with non-extractable key", async () => {
const key = await generateContentKey();
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = new TextEncoder().encode("Hello, Stealth Mail!");

const encrypted = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, data);

const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, encrypted);

expect(new TextDecoder().decode(decrypted)).toBe("Hello, Stealth Mail!");
});
});
53 changes: 53 additions & 0 deletions src/services/crypto/content-key.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export interface ContentKeyOptions {
length?: 128 | 192 | 256;
/**
* Whether the key can be exported using exportKey.
* Defaults to false for security hardening (Issue #1691).
*/
extractable?: boolean;
usages?: KeyUsage[];
}

/**
* Generates an AES-GCM content-encryption key.
* By default, keys are non-extractable (`extractable: false`).
*/
export async function generateContentKey(options: ContentKeyOptions = {}): Promise<CryptoKey> {
const {
length = 256,
extractable = false, // Enforce non-extractable by default
usages = ["encrypt", "decrypt"],
} = options;

return await crypto.subtle.generateKey(
{
name: "AES-GCM",
length,
},
extractable,
usages,
);
}

/**
* Seals content into an envelope by encrypting payload data with a non-extractable AES key
* and wrapping the content key for the intended recipient.
*/
export async function sealEnvelope(
payload: Uint8Array,
recipientPublicKey: CryptoKey,
): Promise<{ encryptedData: ArrayBuffer; wrappedKey: ArrayBuffer; iv: Uint8Array }> {
// Generate non-extractable content key within the wrapping boundary
const contentKey = await generateContentKey({ extractable: false });
const iv = crypto.getRandomValues(new Uint8Array(12));

// Encrypt payload using non-extractable key
const encryptedData = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, contentKey, payload);

// Wrap key within the boundary using Web Crypto wrapKey
const wrappedKey = await crypto.subtle.wrapKey("raw", contentKey, recipientPublicKey, {
name: "RSA-OAEP",
});

return { encryptedData, wrappedKey, iv };
}
Loading