diff --git a/.changeset/recipient-addressed-seal.md b/.changeset/recipient-addressed-seal.md new file mode 100644 index 0000000..9fac8f2 --- /dev/null +++ b/.changeset/recipient-addressed-seal.md @@ -0,0 +1,11 @@ +--- +"@nestm/crypto": minor +--- + +Add `@nestm/crypto/keys`: X25519 keypair generation with raw/DER conversion, an HKDF-SHA256 +helper, and a recipient-addressed `sealTo`/`openFrom` primitive (ephemeral-static X25519 → +HKDF-SHA256 → AES-256-GCM) for wrapping an existing secret to a public key. The wire format is +version- and suite-tagged; the recipient public key is bound into the key schedule by the +library, the nonce is derived and never transmitted, callers may bind key-schedule `info` and +AEAD `aad`, and failures use the existing `CryptoError` codes. Independent of `DataKeyProvider` +and `CipherEngine`; `@nestm/crypto/core` stays free of NestJS, tenant, and cloud SDK imports. diff --git a/package.json b/package.json index ccba0f6..1ad308c 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,11 @@ "import": "./dist/prisma/index.mjs", "default": "./dist/prisma/index.mjs" }, + "./keys": { + "types": "./dist/keys/index.d.mts", + "import": "./dist/keys/index.mjs", + "default": "./dist/keys/index.mjs" + }, "./key-wrap/rsa": { "types": "./dist/key-wrap/rsa/index.d.mts", "import": "./dist/key-wrap/rsa/index.mjs", diff --git a/scripts/check-package.mjs b/scripts/check-package.mjs index 5a7bd7e..2491b0f 100644 --- a/scripts/check-package.mjs +++ b/scripts/check-package.mjs @@ -11,6 +11,7 @@ const expectedExports = [ "./tenant", "./http", "./prisma", + "./keys", "./key-wrap/rsa", "./kms/aws", "./kms/gcp", diff --git a/src/keys/hkdf.ts b/src/keys/hkdf.ts new file mode 100644 index 0000000..9802d72 --- /dev/null +++ b/src/keys/hkdf.ts @@ -0,0 +1,30 @@ +import { hkdfSync } from "node:crypto"; +import { CryptoError } from "../core/errors.js"; + +const SHA256_OUTPUT_BYTES = 32; +const MAX_OUTPUT_BYTES = 255 * SHA256_OUTPUT_BYTES; + +/** + * HKDF-SHA256 (RFC 5869) extract-and-expand. `salt` may be empty (RFC 5869 then + * substitutes a string of `HashLen` zero bytes); `info` may be empty. Input keying + * material must be non-empty, and the requested length is bounded by RFC 5869's + * `255 * HashLen` ceiling. + */ +export function hkdfSha256( + ikm: Uint8Array, + salt: Uint8Array, + info: Uint8Array, + length: number, +): Uint8Array { + if (!Number.isInteger(length) || length < 1 || length > MAX_OUTPUT_BYTES) { + throw new CryptoError("INVALID_ARGUMENT", "The HKDF output length is out of range."); + } + if (ikm.byteLength === 0) { + throw new CryptoError("INVALID_ARGUMENT", "HKDF input keying material must be non-empty."); + } + try { + return new Uint8Array(hkdfSync("sha256", ikm, salt, info, length)); + } catch (error: unknown) { + throw new CryptoError("CIPHER_FAILURE", "Key derivation failed.", { cause: error }); + } +} diff --git a/src/keys/index.ts b/src/keys/index.ts new file mode 100644 index 0000000..0788e38 --- /dev/null +++ b/src/keys/index.ts @@ -0,0 +1,21 @@ +export { + generateX25519KeyPair, + x25519PublicKeyFromRaw, + x25519PrivateKeyFromRaw, + x25519PublicKeyBytes, + x25519PrivateKeyBytes, + type X25519KeyPair, + type X25519PublicKeyInput, + type X25519PrivateKeyInput, +} from "./x25519.js"; +export { hkdfSha256 } from "./hkdf.js"; +export { + sealTo, + openFrom, + openKeyFrom, + inspectSealed, + SEAL_X25519_HKDF_SHA256_A256GCM, + SEALED_OVERHEAD_BYTES, + type SealOptions, + type SealedInfo, +} from "./seal.js"; diff --git a/src/keys/seal.ts b/src/keys/seal.ts new file mode 100644 index 0000000..38c2154 --- /dev/null +++ b/src/keys/seal.ts @@ -0,0 +1,237 @@ +import { createSecretKey, diffieHellman, generateKeyPairSync, KeyObject } from "node:crypto"; +import { Aes256GcmCipher } from "../core/aes-256-gcm.js"; +import { aadBytes, frame, utf8 } from "../core/encoding.js"; +import { authenticationFailed, CryptoError } from "../core/errors.js"; +import type { CipherAad } from "../core/types.js"; +import { hkdfSha256 } from "./hkdf.js"; +import { + toX25519PrivateKey, + toX25519PublicKey, + x25519PublicKeyBytes, + x25519PublicKeyFromRaw, + type X25519PrivateKeyInput, + type X25519PublicKeyInput, +} from "./x25519.js"; + +/** Suite identifier: ephemeral-static X25519, HKDF-SHA256 key schedule, AES-256-GCM seal. */ +export const SEAL_X25519_HKDF_SHA256_A256GCM = "X25519-HKDF-SHA256-A256GCM"; + +const SEAL_FORMAT_VERSION = 0x01; +const SEAL_SUITE_ID = 0x01; +const EPHEMERAL_PUBLIC_BYTES = 32; +const TAG_BYTES = 16; +const HEADER_BYTES = 2 + EPHEMERAL_PUBLIC_BYTES; + +/** Fixed framing + tag overhead a sealed blob adds to its plaintext. */ +export const SEALED_OVERHEAD_BYTES = HEADER_BYTES + TAG_BYTES; + +const MAX_PLAINTEXT_BYTES = 64 * 1024; +const AES_KEY_BYTES = 32; +const NONCE_BYTES = 12; +const OKM_BYTES = AES_KEY_BYTES + NONCE_BYTES; +const INFO_LABEL = "nmc/seal/v1"; +const EMPTY_SALT = new Uint8Array(); + +export interface SealOptions { + /** Bound into the key schedule (protocol/domain context). Must match at open. */ + readonly info?: CipherAad; + /** Bound as AES-GCM associated data (per-message binding). Must match at open. */ + readonly aad?: CipherAad; +} + +export interface SealedInfo { + readonly version: 1; + readonly suite: string; + readonly ephemeralPublicKey: Uint8Array; + readonly ciphertextBytes: number; + readonly authenticated: false; +} + +interface ParsedSeal { + readonly ephemeralPublicKey: Uint8Array; + readonly ciphertext: Uint8Array; + readonly tag: Uint8Array; +} + +function messageBytes(plaintext: Uint8Array | KeyObject): { bytes: Uint8Array; owned: boolean } { + if (plaintext instanceof KeyObject) { + if (plaintext.type !== "secret") { + throw new CryptoError("INVALID_ARGUMENT", "Only a secret key can be sealed."); + } + return { bytes: new Uint8Array(plaintext.export()), owned: true }; + } + if (plaintext instanceof Uint8Array) { + return { bytes: plaintext, owned: false }; + } + throw new CryptoError("INVALID_ARGUMENT", "Sealed plaintext must be bytes or a secret key."); +} + +function scheduleInfo( + ephemeralPublicKey: Uint8Array, + recipientPublicKey: Uint8Array, + info?: CipherAad, +): Uint8Array { + return frame( + utf8(INFO_LABEL), + utf8(SEAL_X25519_HKDF_SHA256_A256GCM), + ephemeralPublicKey, + recipientPublicKey, + aadBytes(info), + ); +} + +export function sealTo( + recipientPublicKey: X25519PublicKeyInput, + plaintext: Uint8Array | KeyObject, + options?: SealOptions, +): Uint8Array { + const recipient = toX25519PublicKey(recipientPublicKey); + const { bytes: message, owned } = messageBytes(plaintext); + try { + if (message.byteLength === 0) { + throw new CryptoError("INVALID_ARGUMENT", "Sealed plaintext must be non-empty."); + } + if (message.byteLength > MAX_PLAINTEXT_BYTES) { + throw new CryptoError("LIMIT_EXCEEDED", "The sealed payload is too large."); + } + const ephemeral = generateKeyPairSync("x25519"); + const ephemeralPublicRaw = x25519PublicKeyBytes(ephemeral.publicKey); + const recipientPublicRaw = x25519PublicKeyBytes(recipient); + + let shared: Buffer | undefined; + let okm: Uint8Array | undefined; + try { + shared = diffieHellman({ privateKey: ephemeral.privateKey, publicKey: recipient }); + okm = hkdfSha256( + new Uint8Array(shared), + EMPTY_SALT, + scheduleInfo(ephemeralPublicRaw, recipientPublicRaw, options?.info), + OKM_BYTES, + ); + const key = createSecretKey(okm.subarray(0, AES_KEY_BYTES)); + const { ciphertext, tag } = new Aes256GcmCipher().encrypt({ + plaintext: message, + key, + nonce: okm.subarray(AES_KEY_BYTES, OKM_BYTES), + aad: aadBytes(options?.aad), + }); + + const sealed = new Uint8Array(HEADER_BYTES + ciphertext.byteLength + TAG_BYTES); + sealed[0] = SEAL_FORMAT_VERSION; + sealed[1] = SEAL_SUITE_ID; + sealed.set(ephemeralPublicRaw, 2); + sealed.set(ciphertext, HEADER_BYTES); + sealed.set(tag, HEADER_BYTES + ciphertext.byteLength); + return sealed; + } finally { + shared?.fill(0); + okm?.fill(0); + } + } finally { + if (owned) message.fill(0); + } +} + +function parseSealed(sealed: Uint8Array): ParsedSeal { + if (!(sealed instanceof Uint8Array)) { + throw new CryptoError("MALFORMED_ENVELOPE", "The sealed blob must be bytes."); + } + if (sealed.byteLength <= SEALED_OVERHEAD_BYTES) { + throw new CryptoError("MALFORMED_ENVELOPE", "The sealed blob is truncated."); + } + if (sealed.byteLength > MAX_PLAINTEXT_BYTES + SEALED_OVERHEAD_BYTES) { + throw new CryptoError("LIMIT_EXCEEDED", "The sealed blob is too large."); + } + if (sealed[0] !== SEAL_FORMAT_VERSION) { + throw new CryptoError("UNSUPPORTED_VERSION", "The sealed blob version is unsupported."); + } + if (sealed[1] !== SEAL_SUITE_ID) { + throw new CryptoError("UNSUPPORTED_CIPHER", "The sealed blob suite is unsupported."); + } + const tagStart = sealed.byteLength - TAG_BYTES; + return { + ephemeralPublicKey: new Uint8Array(sealed.subarray(2, HEADER_BYTES)), + ciphertext: new Uint8Array(sealed.subarray(HEADER_BYTES, tagStart)), + tag: new Uint8Array(sealed.subarray(tagStart)), + }; +} + +function openToBytes( + recipientPrivateKey: X25519PrivateKeyInput, + sealed: Uint8Array, + options: SealOptions | undefined, +): Buffer { + const recipient = toX25519PrivateKey(recipientPrivateKey); + const parsed = parseSealed(sealed); + const recipientPublicKey = x25519PublicKeyBytes(recipient); + + let ephemeral: KeyObject; + try { + ephemeral = x25519PublicKeyFromRaw(parsed.ephemeralPublicKey); + } catch (error: unknown) { + throw authenticationFailed({ cause: error }); + } + + let shared: Buffer | undefined; + let okm: Uint8Array | undefined; + try { + try { + shared = diffieHellman({ privateKey: recipient, publicKey: ephemeral }); + } catch (error: unknown) { + // Node rejects a low-order / all-zero ephemeral key at derivation. + throw authenticationFailed({ cause: error }); + } + okm = hkdfSha256( + new Uint8Array(shared), + EMPTY_SALT, + scheduleInfo(parsed.ephemeralPublicKey, recipientPublicKey, options?.info), + OKM_BYTES, + ); + const key = createSecretKey(okm.subarray(0, AES_KEY_BYTES)); + const plaintext = new Aes256GcmCipher().decrypt({ + ciphertext: parsed.ciphertext, + key, + nonce: okm.subarray(AES_KEY_BYTES, OKM_BYTES), + tag: parsed.tag, + aad: aadBytes(options?.aad), + }); + return Buffer.from(plaintext); + } finally { + shared?.fill(0); + okm?.fill(0); + } +} + +export function openFrom( + recipientPrivateKey: X25519PrivateKeyInput, + sealed: Uint8Array, + options?: SealOptions, +): Buffer { + return openToBytes(recipientPrivateKey, sealed, options); +} + +/** Open a sealed secret straight into a `KeyObject` so the caller never holds raw bytes. */ +export function openKeyFrom( + recipientPrivateKey: X25519PrivateKeyInput, + sealed: Uint8Array, + options?: SealOptions, +): KeyObject { + const raw = openToBytes(recipientPrivateKey, sealed, options); + try { + return createSecretKey(raw); + } finally { + raw.fill(0); + } +} + +/** Untrusted framing metadata. Nothing here is authenticated until `openFrom` succeeds. */ +export function inspectSealed(sealed: Uint8Array): SealedInfo { + const parsed = parseSealed(sealed); + return Object.freeze({ + version: 1, + suite: SEAL_X25519_HKDF_SHA256_A256GCM, + ephemeralPublicKey: parsed.ephemeralPublicKey, + ciphertextBytes: parsed.ciphertext.byteLength, + authenticated: false, + }); +} diff --git a/src/keys/x25519.ts b/src/keys/x25519.ts new file mode 100644 index 0000000..abb6a2c --- /dev/null +++ b/src/keys/x25519.ts @@ -0,0 +1,146 @@ +import { createPrivateKey, createPublicKey, generateKeyPairSync, KeyObject } from "node:crypto"; +import { CryptoError } from "../core/errors.js"; + +const X25519 = "x25519"; +const RAW_KEY_BYTES = 32; + +// RFC 8410 DER framing for X25519 keys. These prefixes are fixed by the OID, but the +// import path never *trusts* them: raw bytes are wrapped, parsed by Node, and the parsed +// key is re-exported and compared so a malformed input cannot slip through. +const SPKI_PREFIX = Uint8Array.from([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x03, 0x21, 0x00, +]); +const PKCS8_PREFIX = Uint8Array.from([ + 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x6e, 0x04, 0x22, 0x04, 0x20, +]); + +export type X25519PublicKeyInput = KeyObject | Uint8Array | Parameters[0]; +export type X25519PrivateKeyInput = KeyObject | Uint8Array | Parameters[0]; + +export interface X25519KeyPair { + readonly publicKey: KeyObject; + readonly privateKey: KeyObject; +} + +export function generateX25519KeyPair(): X25519KeyPair { + const { publicKey, privateKey } = generateKeyPairSync(X25519); + return Object.freeze({ publicKey, privateKey }); +} + +function assertX25519(key: KeyObject, type: "public" | "private"): KeyObject { + if (key.asymmetricKeyType !== X25519) { + throw new CryptoError("INVALID_KEY", "An X25519 key is required."); + } + if (key.type !== type) { + throw new CryptoError("INVALID_KEY", `An X25519 ${type} key is required.`); + } + return key; +} + +function prefixMatches(der: Buffer, prefix: Uint8Array): boolean { + if (der.byteLength !== prefix.byteLength + RAW_KEY_BYTES) return false; + for (let index = 0; index < prefix.byteLength; index += 1) { + if (der[index] !== prefix[index]) return false; + } + return true; +} + +function concat(prefix: Uint8Array, raw: Uint8Array): Uint8Array { + const out = new Uint8Array(prefix.byteLength + raw.byteLength); + out.set(prefix, 0); + out.set(raw, prefix.byteLength); + return out; +} + +function assertRawLength(raw: Uint8Array, label: string): void { + if (!(raw instanceof Uint8Array) || raw.byteLength !== RAW_KEY_BYTES) { + throw new CryptoError("INVALID_KEY", `An X25519 ${label} key must be 32 bytes.`); + } +} + +/** Normalize any accepted public-key input to an X25519 public `KeyObject`. */ +export function toX25519PublicKey(input: X25519PublicKeyInput): KeyObject { + try { + if (input instanceof Uint8Array) return x25519PublicKeyFromRaw(input); + const key = + input instanceof KeyObject + ? input.type === "private" + ? createPublicKey(input) + : input + : createPublicKey(input); + return assertX25519(key, "public"); + } catch (error: unknown) { + if (error instanceof CryptoError) throw error; + throw new CryptoError("INVALID_KEY", "The X25519 public key is invalid.", { cause: error }); + } +} + +/** Normalize any accepted private-key input to an X25519 private `KeyObject`. */ +export function toX25519PrivateKey(input: X25519PrivateKeyInput): KeyObject { + try { + if (input instanceof Uint8Array) return x25519PrivateKeyFromRaw(input); + const key = input instanceof KeyObject ? input : createPrivateKey(input); + return assertX25519(key, "private"); + } catch (error: unknown) { + if (error instanceof CryptoError) throw error; + throw new CryptoError("INVALID_KEY", "The X25519 private key is invalid.", { cause: error }); + } +} + +export function x25519PublicKeyFromRaw(raw: Uint8Array): KeyObject { + assertRawLength(raw, "public"); + let key: KeyObject; + try { + key = createPublicKey({ + key: Buffer.from(concat(SPKI_PREFIX, raw)), + format: "der", + type: "spki", + }); + } catch (error: unknown) { + throw new CryptoError("INVALID_KEY", "The X25519 public key bytes are invalid.", { + cause: error, + }); + } + assertX25519(key, "public"); + return key; +} + +export function x25519PrivateKeyFromRaw(raw: Uint8Array): KeyObject { + assertRawLength(raw, "private"); + const der = Buffer.from(concat(PKCS8_PREFIX, raw)); + try { + const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" }); + return assertX25519(key, "private"); + } catch (error: unknown) { + if (error instanceof CryptoError) throw error; + throw new CryptoError("INVALID_KEY", "The X25519 private key bytes are invalid.", { + cause: error, + }); + } finally { + der.fill(0); + } +} + +/** Raw 32-byte RFC 7748 u-coordinate of an X25519 public key. */ +export function x25519PublicKeyBytes(input: X25519PublicKeyInput): Uint8Array { + const key = toX25519PublicKey(input); + const der = key.export({ format: "der", type: "spki" }); + if (!prefixMatches(der, SPKI_PREFIX)) { + throw new CryptoError("INVALID_KEY", "The X25519 public key structure is unexpected."); + } + return new Uint8Array(der.subarray(SPKI_PREFIX.byteLength)); +} + +/** Raw 32-byte scalar of an X25519 private key. The intermediate DER buffer is zeroed. */ +export function x25519PrivateKeyBytes(input: X25519PrivateKeyInput): Uint8Array { + const key = toX25519PrivateKey(input); + const der = key.export({ format: "der", type: "pkcs8" }); + try { + if (!prefixMatches(der, PKCS8_PREFIX)) { + throw new CryptoError("INVALID_KEY", "The X25519 private key structure is unexpected."); + } + return new Uint8Array(der.subarray(PKCS8_PREFIX.byteLength)); + } finally { + der.fill(0); + } +} diff --git a/tests/unit/keys.test.ts b/tests/unit/keys.test.ts new file mode 100644 index 0000000..147dc1f --- /dev/null +++ b/tests/unit/keys.test.ts @@ -0,0 +1,198 @@ +import { createSecretKey, generateKeyPairSync, randomBytes, type KeyObject } from "node:crypto"; +import { + generateX25519KeyPair, + hkdfSha256, + inspectSealed, + openFrom, + openKeyFrom, + sealTo, + SEAL_X25519_HKDF_SHA256_A256GCM, + SEALED_OVERHEAD_BYTES, + x25519PrivateKeyBytes, + x25519PrivateKeyFromRaw, + x25519PublicKeyBytes, + x25519PublicKeyFromRaw, +} from "../../src/keys/index.js"; + +const hex = (value: Uint8Array): string => Buffer.from(value).toString("hex"); + +// First ciphertext byte of a sealed blob: 2-byte header + 32-byte ephemeral key. +const HEADER_OFFSET = 34; + +describe("x25519 key conversion", () => { + it("round-trips public and private keys through raw bytes", () => { + const pair = generateX25519KeyPair(); + const publicRaw = x25519PublicKeyBytes(pair.publicKey); + const privateRaw = x25519PrivateKeyBytes(pair.privateKey); + + expect(publicRaw.byteLength).toBe(32); + expect(privateRaw.byteLength).toBe(32); + expect(hex(x25519PublicKeyBytes(x25519PublicKeyFromRaw(publicRaw)))).toBe(hex(publicRaw)); + expect(hex(x25519PrivateKeyBytes(x25519PrivateKeyFromRaw(privateRaw)))).toBe(hex(privateRaw)); + }); + + it("derives the public coordinate consistently from a private key", () => { + const pair = generateX25519KeyPair(); + expect(hex(x25519PublicKeyBytes(pair.privateKey))).toBe( + hex(x25519PublicKeyBytes(pair.publicKey)), + ); + }); + + it("rejects non-X25519 keys and malformed raw lengths", () => { + const ed = generateKeyPairSync("ed25519"); + expect(() => x25519PublicKeyBytes(ed.publicKey)).toThrowError(/X25519/); + expect(() => x25519PublicKeyFromRaw(new Uint8Array(31))).toThrowError(/32 bytes/); + expect(() => x25519PrivateKeyFromRaw(new Uint8Array(33))).toThrowError(/32 bytes/); + }); +}); + +describe("sealTo / openFrom", () => { + it("round-trips a byte payload for the intended recipient", () => { + const pair = generateX25519KeyPair(); + const payload = randomBytes(32); + const sealed = sealTo(pair.publicKey, payload); + + expect(sealed.byteLength).toBe(payload.byteLength + SEALED_OVERHEAD_BYTES); + expect(hex(openFrom(pair.privateKey, sealed))).toBe(hex(payload)); + }); + + it("round-trips through raw recipient keys", () => { + const pair = generateX25519KeyPair(); + const payload = randomBytes(32); + const sealed = sealTo(x25519PublicKeyBytes(pair.publicKey), payload); + const opened = openFrom(x25519PrivateKeyBytes(pair.privateKey), sealed); + expect(hex(opened)).toBe(hex(payload)); + }); + + it("seals a secret KeyObject and opens it back into one", () => { + const pair = generateX25519KeyPair(); + const secret = createSecretKey(randomBytes(32)); + const sealed = sealTo(pair.publicKey, secret); + const opened: KeyObject = openKeyFrom(pair.privateKey, sealed); + expect(opened.type).toBe("secret"); + expect(hex(opened.export())).toBe(hex(secret.export())); + }); + + it("binds info and aad — a mismatch fails authentication", () => { + const pair = generateX25519KeyPair(); + const payload = randomBytes(32); + const sealed = sealTo(pair.publicKey, payload, { info: "domain:v1", aad: "grant:42" }); + + expect(hex(openFrom(pair.privateKey, sealed, { info: "domain:v1", aad: "grant:42" }))).toBe( + hex(payload), + ); + expect(() => + openFrom(pair.privateKey, sealed, { info: "domain:v2", aad: "grant:42" }), + ).toThrowError(/authentication failed/i); + expect(() => + openFrom(pair.privateKey, sealed, { info: "domain:v1", aad: "grant:43" }), + ).toThrowError(/authentication failed/i); + expect(() => openFrom(pair.privateKey, sealed)).toThrowError(/authentication failed/i); + }); + + it("cannot be opened by a different recipient", () => { + const recipient = generateX25519KeyPair(); + const stranger = generateX25519KeyPair(); + const sealed = sealTo(recipient.publicKey, randomBytes(32)); + expect(() => openFrom(stranger.privateKey, sealed)).toThrowError(/authentication failed/i); + }); + + it("rejects a tampered ciphertext, tag, or ephemeral key", () => { + const pair = generateX25519KeyPair(); + const sealed = sealTo(pair.publicKey, randomBytes(32)); + + const flipCipher = Uint8Array.from(sealed); + flipCipher[HEADER_OFFSET] = (flipCipher[HEADER_OFFSET] ?? 0) ^ 0x01; + expect(() => openFrom(pair.privateKey, flipCipher)).toThrowError(/authentication failed/i); + + const flipTag = Uint8Array.from(sealed); + const lastIndex = flipTag.byteLength - 1; + flipTag[lastIndex] = (flipTag[lastIndex] ?? 0) ^ 0x01; + expect(() => openFrom(pair.privateKey, flipTag)).toThrowError(/authentication failed/i); + + const zeroEphemeral = Uint8Array.from(sealed); + zeroEphemeral.fill(0, 2, 34); + expect(() => openFrom(pair.privateKey, zeroEphemeral)).toThrowError(/authentication failed/i); + }); + + it("rejects truncated, oversized, and mis-tagged blobs", () => { + const pair = generateX25519KeyPair(); + expect(() => openFrom(pair.privateKey, new Uint8Array(SEALED_OVERHEAD_BYTES))).toThrowError( + /truncated/i, + ); + expect(() => + openFrom(pair.privateKey, new Uint8Array(64 * 1024 + SEALED_OVERHEAD_BYTES + 1)), + ).toThrowError(/too large/i); + + const sealed = sealTo(pair.publicKey, randomBytes(32)); + const badVersion = Uint8Array.from(sealed); + badVersion[0] = 0x02; + expect(() => openFrom(pair.privateKey, badVersion)).toThrowError(/version is unsupported/i); + const badSuite = Uint8Array.from(sealed); + badSuite[1] = 0x02; + expect(() => openFrom(pair.privateKey, badSuite)).toThrowError(/suite is unsupported/i); + }); + + it("rejects an empty payload", () => { + const pair = generateX25519KeyPair(); + expect(() => sealTo(pair.publicKey, new Uint8Array())).toThrowError(/non-empty/i); + }); + + it("exposes framing metadata without authenticating", () => { + const pair = generateX25519KeyPair(); + const sealed = sealTo(pair.publicKey, randomBytes(32)); + const info = inspectSealed(sealed); + expect(info).toMatchObject({ + version: 1, + suite: SEAL_X25519_HKDF_SHA256_A256GCM, + ciphertextBytes: 32, + authenticated: false, + }); + expect(info.ephemeralPublicKey.byteLength).toBe(32); + }); + + it("produces a fresh ephemeral key per seal", () => { + const pair = generateX25519KeyPair(); + const a = sealTo(pair.publicKey, randomBytes(32)); + const b = sealTo(pair.publicKey, randomBytes(32)); + expect(hex(a.subarray(2, 34))).not.toBe(hex(b.subarray(2, 34))); + }); +}); + +describe("hkdfSha256 (RFC 5869 SHA-256 vectors)", () => { + it("matches test case 1", () => { + const okm = hkdfSha256( + Buffer.from("0b".repeat(22), "hex"), + Buffer.from("000102030405060708090a0b0c", "hex"), + Buffer.from("f0f1f2f3f4f5f6f7f8f9", "hex"), + 42, + ); + expect(hex(okm)).toBe( + "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865", + ); + }); + + it("matches test case 3 (empty salt and info)", () => { + const okm = hkdfSha256( + Buffer.from("0b".repeat(22), "hex"), + new Uint8Array(), + new Uint8Array(), + 42, + ); + expect(hex(okm)).toBe( + "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8", + ); + }); + + it("rejects empty keying material and out-of-range lengths", () => { + expect(() => hkdfSha256(new Uint8Array(), new Uint8Array(), new Uint8Array(), 42)).toThrowError( + /non-empty/i, + ); + expect(() => + hkdfSha256(new Uint8Array([1]), new Uint8Array(), new Uint8Array(), 0), + ).toThrowError(/out of range/i); + expect(() => + hkdfSha256(new Uint8Array([1]), new Uint8Array(), new Uint8Array(), 255 * 32 + 1), + ).toThrowError(/out of range/i); + }); +}); diff --git a/tsdown.config.ts b/tsdown.config.ts index f3014aa..14a70a2 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ "src/tenant/index.ts", "src/http/index.ts", "src/prisma/index.ts", + "src/keys/index.ts", "src/key-wrap/rsa/index.ts", "src/kms/aws/index.ts", "src/kms/gcp/index.ts",