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
11 changes: 11 additions & 0 deletions crypto/_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright 2018-2026 the Deno authors. MIT license.

/**
* Proxy type of {@code Uint8Array<ArrayBuffer>} or {@code Uint8Array} in TypeScript 5.7 or below respectively.
*
* This type is internal utility type and should not be used directly.
*
* @internal @private
*/

export type Uint8Array_ = ReturnType<Uint8Array["slice"]>;
3 changes: 2 additions & 1 deletion crypto/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"exports": {
".": "./mod.ts",
"./crypto": "./crypto.ts",
"./timing-safe-equal": "./timing_safe_equal.ts"
"./timing-safe-equal": "./timing_safe_equal.ts",
"./unstable-aes-gcm": "./unstable_aes_gcm.ts"
},
"exclude": [
"_wasm/target"
Expand Down
165 changes: 165 additions & 0 deletions crypto/unstable_aes_gcm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright 2018-2026 the Deno authors. MIT license.
// This module is browser compatible.

import type { Uint8Array_ } from "./_types.ts";
export type { Uint8Array_ };

/**
* High-level AES-GCM authenticated encryption with automatic nonce generation.
*
* With random nonces, do not encrypt more than ~2^32 messages under the same
* key. Beyond this limit, nonce collision probability becomes non-negligible.
*
* @example Usage
* ```ts
* import { encryptAesGcm, decryptAesGcm } from "@std/crypto/unstable-aes-gcm";
* import { assertEquals } from "@std/assert";
*
* const key = await crypto.subtle.generateKey(
* { name: "AES-GCM", length: 256 },
* false,
* ["encrypt", "decrypt"],
* );
*
* const plaintext = new TextEncoder().encode("hello world");
* const encrypted = await encryptAesGcm(key, plaintext);
* const decrypted = await decryptAesGcm(key, encrypted);
*
* assertEquals(decrypted, plaintext);
* ```
*
* @module
*/

const NONCE_LENGTH = 12;
const TAG_LENGTH = 16;
const OVERHEAD = NONCE_LENGTH + TAG_LENGTH;

/** Options for {@linkcode encryptAesGcm} and {@linkcode decryptAesGcm}. */
export interface AesGcmOptions {
/** Additional authenticated data. Authenticated but not encrypted. */
additionalData?: BufferSource;
}

/**
* Encrypts plaintext using AES-GCM with a random 96-bit nonce.
*
* Returns `nonce (12 bytes) || ciphertext || tag (16 bytes)`.
*
* @example Usage
* ```ts
* import { encryptAesGcm } from "@std/crypto/unstable-aes-gcm";
* import { assertNotEquals } from "@std/assert";
*
* const key = await crypto.subtle.generateKey(
* { name: "AES-GCM", length: 256 },
* false,
* ["encrypt", "decrypt"],
* );
*
* const encrypted = await encryptAesGcm(
* key,
* new TextEncoder().encode("hello world"),
* );
*
* assertNotEquals(encrypted.length, 0);
* ```
*
* @param key The AES-GCM `CryptoKey` to encrypt with.
* @param plaintext The data to encrypt.
* @param options Optional additional authenticated data.
* @returns The concatenated nonce, ciphertext, and authentication tag.
*
* @remarks With random nonces, do not encrypt more than ~2^32 messages
* under the same key. Beyond this limit, nonce collision probability
* becomes non-negligible.
*
* @see {@link https://csrc.nist.gov/pubs/sp/800/38/d/final | NIST SP 800-38D} Section 8.3
*/
export async function encryptAesGcm(
key: CryptoKey,
plaintext: BufferSource,
options?: AesGcmOptions,
): Promise<Uint8Array_> {
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LENGTH));

const params: AesGcmParams = {
name: "AES-GCM",
iv: nonce,
tagLength: TAG_LENGTH * 8,
};
if (options?.additionalData !== undefined) {
params.additionalData = options.additionalData;
}

const ciphertextAndTag = new Uint8Array(
await crypto.subtle.encrypt(params, key, plaintext),
);

const result = new Uint8Array(NONCE_LENGTH + ciphertextAndTag.byteLength);
result.set(nonce);
result.set(ciphertextAndTag, NONCE_LENGTH);
return result;
}

/**
* Decrypts data produced by {@linkcode encryptAesGcm}.
*
* Expects input in the format `nonce (12 bytes) || ciphertext || tag (16 bytes)`.
*
* @example Usage
* ```ts
* import { decryptAesGcm, encryptAesGcm } from "@std/crypto/unstable-aes-gcm";
* import { assertEquals } from "@std/assert";
*
* const key = await crypto.subtle.generateKey(
* { name: "AES-GCM", length: 256 },
* false,
* ["encrypt", "decrypt"],
* );
*
* const plaintext = new TextEncoder().encode("hello world");
* const encrypted = await encryptAesGcm(key, plaintext);
*
* assertEquals(await decryptAesGcm(key, encrypted), plaintext);
* ```
*
* @param key The AES-GCM `CryptoKey` to decrypt with.
* @param data The wire-format output from {@linkcode encryptAesGcm}: nonce (12 B) || ciphertext || tag (16 B).
* @param options Optional additional authenticated data (must match what was used during encryption).
* @returns The decrypted plaintext.
* @throws {RangeError} If `data` is shorter than 28 bytes (12 nonce + 16 tag).
* @throws {DOMException} If authentication fails (wrong key, tampered data, or
* mismatched additional data).
*/
export async function decryptAesGcm(
key: CryptoKey,
data: BufferSource,
options?: AesGcmOptions,
): Promise<Uint8Array_> {
const bytes = ArrayBuffer.isView(data)
? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
: new Uint8Array(data);

if (bytes.byteLength < OVERHEAD) {
throw new RangeError(
`Data is too short: expected at least ${OVERHEAD} bytes, got ${bytes.byteLength}`,
);
}

const nonce = bytes.subarray(0, NONCE_LENGTH);
const ciphertextAndTag = bytes.subarray(NONCE_LENGTH);

const params: AesGcmParams = {
name: "AES-GCM",
iv: nonce,
tagLength: TAG_LENGTH * 8,
};
if (options?.additionalData !== undefined) {
params.additionalData = options.additionalData;
}

return new Uint8Array(
await crypto.subtle.decrypt(params, key, ciphertextAndTag),
);
}
194 changes: 194 additions & 0 deletions crypto/unstable_aes_gcm_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
// Copyright 2018-2026 the Deno authors. MIT license.

import { assertEquals, assertNotEquals, assertRejects } from "@std/assert";
import { decryptAesGcm, encryptAesGcm } from "./unstable_aes_gcm.ts";

const encoder = new TextEncoder();

function generateKey(
length: 128 | 256,
): Promise<CryptoKey> {
return crypto.subtle.generateKey(
{ name: "AES-GCM", length },
true,
["encrypt", "decrypt"],
);
}

Deno.test("encryptAesGcm()/decryptAesGcm() round-trips empty plaintext", async () => {
const key = await generateKey(256);
const plaintext = new Uint8Array(0);
const encrypted = await encryptAesGcm(key, plaintext);
const decrypted = await decryptAesGcm(key, encrypted);
assertEquals(decrypted, plaintext);
});

Deno.test("encryptAesGcm()/decryptAesGcm() round-trips non-empty plaintext", async () => {
const key = await generateKey(256);
const plaintext = crypto.getRandomValues(new Uint8Array(1024));
const encrypted = await encryptAesGcm(key, plaintext);
const decrypted = await decryptAesGcm(key, encrypted);
assertEquals(decrypted, plaintext);
});

Deno.test("encryptAesGcm() output length is 12 + plaintext.length + 16", async () => {
const key = await generateKey(256);
for (const size of [0, 1, 256]) {
const plaintext = new Uint8Array(size);
const encrypted = await encryptAesGcm(key, plaintext);
assertEquals(encrypted.byteLength, 12 + size + 16);
}
});

Deno.test("encryptAesGcm() generates different nonces per call", async () => {
const key = await generateKey(256);
const plaintext = encoder.encode("hello");
const a = await encryptAesGcm(key, plaintext);
const b = await encryptAesGcm(key, plaintext);
assertNotEquals(a.subarray(0, 12), b.subarray(0, 12));
});

Deno.test("encryptAesGcm()/decryptAesGcm() round-trips with matching additionalData", async () => {
const key = await generateKey(256);
const plaintext = encoder.encode("secret");
const aad = encoder.encode("metadata");
const encrypted = await encryptAesGcm(key, plaintext, {
additionalData: aad,
});
const decrypted = await decryptAesGcm(key, encrypted, {
additionalData: aad,
});
assertEquals(decrypted, plaintext);
});

Deno.test("decryptAesGcm() rejects with wrong additionalData", async () => {
const key = await generateKey(256);
const encrypted = await encryptAesGcm(key, encoder.encode("secret"), {
additionalData: encoder.encode("correct"),
});
await assertRejects(
() =>
decryptAesGcm(key, encrypted, {
additionalData: encoder.encode("wrong"),
}),
DOMException,
);
});

Deno.test("decryptAesGcm() rejects when additionalData expected but not provided", async () => {
const key = await generateKey(256);
const encrypted = await encryptAesGcm(key, encoder.encode("secret"), {
additionalData: encoder.encode("metadata"),
});
await assertRejects(
() => decryptAesGcm(key, encrypted),
DOMException,
);
});

Deno.test("decryptAesGcm() rejects on tampered ciphertext", async () => {
const key = await generateKey(256);
const encrypted = await encryptAesGcm(key, encoder.encode("hello"));
encrypted[14]! ^= 0xff;
await assertRejects(
() => decryptAesGcm(key, encrypted),
DOMException,
);
});

Deno.test("decryptAesGcm() throws RangeError for data shorter than 28 bytes", async () => {
const key = await generateKey(256);
for (const size of [0, 1, 12, 27]) {
await assertRejects(
() => decryptAesGcm(key, new Uint8Array(size)),
RangeError,
`expected at least`,
);
}
});

Deno.test("decryptAesGcm() rejects with a different key", async () => {
const key1 = await generateKey(256);
const key2 = await generateKey(256);
const encrypted = await encryptAesGcm(key1, encoder.encode("hello"));
await assertRejects(
() => decryptAesGcm(key2, encrypted),
DOMException,
);
});

Deno.test("encryptAesGcm() output is valid Web Crypto AES-GCM", async () => {
const key = await generateKey(256);
const plaintext = encoder.encode("interop test");
const encrypted = await encryptAesGcm(key, plaintext);

const nonce = encrypted.subarray(0, 12);
const ciphertextAndTag = encrypted.subarray(12);

const decrypted = new Uint8Array(
await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce, tagLength: 128 },
key,
ciphertextAndTag,
),
);
assertEquals(decrypted, plaintext);
});

Deno.test("decryptAesGcm() decrypts manually constructed wire format", async () => {
const key = await generateKey(256);
const plaintext = encoder.encode("manual wire format");
const nonce = crypto.getRandomValues(new Uint8Array(12));

const ciphertextAndTag = new Uint8Array(
await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce, tagLength: 128 },
key,
plaintext,
),
);

const wire = new Uint8Array(12 + ciphertextAndTag.byteLength);
wire.set(nonce);
wire.set(ciphertextAndTag, 12);

const decrypted = await decryptAesGcm(key, wire);
assertEquals(decrypted, plaintext);
});

Deno.test("encryptAesGcm()/decryptAesGcm() works with AES-128", async () => {
const key = await generateKey(128);
const plaintext = encoder.encode("AES-128 test");
const encrypted = await encryptAesGcm(key, plaintext);
const decrypted = await decryptAesGcm(key, encrypted);
assertEquals(decrypted, plaintext);
});

Deno.test("encryptAesGcm()/decryptAesGcm() round-trips ArrayBuffer inputs", async () => {
const key = await generateKey(256);
const plaintext = encoder.encode("arraybuffer test");
const encrypted = await encryptAesGcm(key, plaintext.buffer as ArrayBuffer);
const decrypted = await decryptAesGcm(
key,
encrypted.buffer as ArrayBuffer,
);
assertEquals(decrypted, plaintext);
});

Deno.test("encryptAesGcm()/decryptAesGcm() round-trips DataView inputs", async () => {
const key = await generateKey(256);
const plaintext = encoder.encode("dataview test");
const plaintextView = new DataView(
plaintext.buffer as ArrayBuffer,
plaintext.byteOffset,
plaintext.byteLength,
);
const encrypted = await encryptAesGcm(key, plaintextView);
const encryptedView = new DataView(
encrypted.buffer as ArrayBuffer,
encrypted.byteOffset,
encrypted.byteLength,
);
const decrypted = await decryptAesGcm(key, encryptedView);
assertEquals(decrypted, plaintext);
});
Loading