From 19f18bd8f66631d7857b6cfef090229f02e044be Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 14:43:57 -0400 Subject: [PATCH 1/5] feat(sdk): add ML-KEM (FIPS 203) key access and rewrap support (DSPX-3229) Adds ML-KEM (Kyber, NIST FIPS 203) key access objects and rewrap support on top of the key-algorithm refactor: - PEM-ify ML-KEM keys and surface JWA names (ML-KEM-768+A192KW, ML-KEM-1024+A256KW) - require kid for ML-KEM key access - platform-interoperable key wrap; ASN.1/SPKI structure and length validation - drop ML-KEM-512 support (mlkem:768 / mlkem:1024 only) - roundtrip CI: provision ML-KEM keys via platform keygen --- .github/workflows/roundtrip/init-temp-keys.sh | 14 + .github/workflows/roundtrip/opentdf.yaml | 17 + CLAUDE.md | 1 + cli/package-lock.json | 59 +++- lib/package-lock.json | 57 ++++ lib/package.json | 1 + lib/src/access.ts | 7 + lib/src/crypto/pemPublicToCrypto.ts | 3 + lib/tdf3/src/client/index.ts | 5 +- lib/tdf3/src/crypto/core/key-format.ts | 63 +++- lib/tdf3/src/crypto/core/keys.ts | 34 ++ lib/tdf3/src/crypto/core/mlkem-asn1.ts | 218 +++++++++++++ lib/tdf3/src/crypto/core/mlkem.ts | 89 ++++++ lib/tdf3/src/crypto/declarations.ts | 83 ++++- lib/tdf3/src/crypto/index.ts | 14 + lib/tdf3/src/models/key-access.ts | 104 +++++- lib/tdf3/src/tdf.ts | 99 +++++- lib/tests/mocha/encrypt-decrypt.spec.ts | 66 +--- lib/tests/mocha/unit/crypto-di.spec.ts | 28 ++ lib/tests/mocha/unit/crypto/mlkem.spec.ts | 150 +++++++++ lib/tests/mocha/unit/mlkem-key-access.spec.ts | 103 ++++++ lib/tests/mocha/unit/tdf.spec.ts | 62 ++-- lib/tests/server.ts | 166 +++++++++- spec/DSPX-3229.md | 297 ++++++++++++++++++ web-app/package-lock.json | 59 +++- web-app/src/App.tsx | 76 ++++- web-app/tests/tests/roundtrip.spec.ts | 47 +++ 27 files changed, 1791 insertions(+), 131 deletions(-) create mode 120000 CLAUDE.md create mode 100644 lib/tdf3/src/crypto/core/mlkem-asn1.ts create mode 100644 lib/tdf3/src/crypto/core/mlkem.ts create mode 100644 lib/tests/mocha/unit/crypto/mlkem.spec.ts create mode 100644 lib/tests/mocha/unit/mlkem-key-access.spec.ts create mode 100644 spec/DSPX-3229.md diff --git a/.github/workflows/roundtrip/init-temp-keys.sh b/.github/workflows/roundtrip/init-temp-keys.sh index 9a2d296a6..967d7da27 100755 --- a/.github/workflows/roundtrip/init-temp-keys.sh +++ b/.github/workflows/roundtrip/init-temp-keys.sh @@ -68,6 +68,20 @@ openssl req -x509 -nodes -newkey RSA:2048 -subj "/CN=kas" -keyout "$opt_output/k openssl ecparam -name prime256v1 >ecparams.tmp openssl req -x509 -nodes -newkey ec:ecparams.tmp -subj "/CN=kas" -keyout "$opt_output/kas-ec-private.pem" -out "$opt_output/kas-ec-cert.pem" -days 365 +# ML-KEM KAS keys (768 & 1024). openssl on the runner is too old to emit ML-KEM, +# so delegate to the platform's own keygen command — its ocrypto encoding is the +# authoritative one the KAS expects, so there is no hand-rolled ASN.1 to keep in +# sync. Reuses the platform clone already checked out for the roundtrip test. +# script_dir must be absolute: go requires GOWORK to be an absolute path and +# rejects a relative one with "invalid GOWORK: not an absolute path". +script_dir="$(cd "$(dirname "$0")" >/dev/null && pwd)" +if [ -d "${script_dir}/platform/service" ]; then + GOWORK="${script_dir}/platform/go.work" \ + go run "${script_dir}/platform/service/cmd/keygen" -output "$opt_output" || exit 1 +else + go run github.com/opentdf/platform/service/cmd/keygen@latest -output "$opt_output" || exit 1 +fi + if [ "$opt_hsm" = true ]; then pkcs11-tool --module "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_MODULEPATH}" --login --pin "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_PIN}" --write-object kas-private.pem --type privkey --label "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_KEYS_RSA_LABEL}" pkcs11-tool --module "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_MODULEPATH}" --login --pin "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_PIN}" --write-object kas-cert.pem --type cert --label "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_KEYS_RSA_LABEL}" diff --git a/.github/workflows/roundtrip/opentdf.yaml b/.github/workflows/roundtrip/opentdf.yaml index bf1b206bb..4bd47777d 100644 --- a/.github/workflows/roundtrip/opentdf.yaml +++ b/.github/workflows/roundtrip/opentdf.yaml @@ -10,6 +10,11 @@ logger: # password: changeme services: kas: + # Preview features gate the non-RSA rewrap paths in newer platform builds. + # ec-wrapped is needed for the default ZTDF roundtrip; mlkem for the ML-KEM tests. + preview: + ec_tdf_enabled: true + mlkem_tdf_enabled: true keyring: - kid: e1 alg: ec:secp256r1 @@ -21,6 +26,10 @@ services: - kid: r1 alg: rsa:2048 legacy: true + - kid: mlkem768 + alg: mlkem:768 + - kid: mlkem1024 + alg: mlkem:1024 entityresolution: url: http://localhost:65432/auth log_level: info @@ -90,4 +99,12 @@ server: alg: ec:secp256r1 private: kas-ec-private.pem cert: kas-ec-cert.pem + - kid: mlkem768 + alg: mlkem:768 + private: kas-mlkem768-private.pem + cert: kas-mlkem768-public.pem + - kid: mlkem1024 + alg: mlkem:1024 + private: kas-mlkem1024-private.pem + cert: kas-mlkem1024-public.pem port: 8080 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/cli/package-lock.json b/cli/package-lock.json index 6f36035fe..5f3d68a31 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -393,6 +393,62 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@npmcli/fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.1.tgz", @@ -409,11 +465,12 @@ "node_modules/@opentdf/sdk": { "version": "0.20.0", "resolved": "file:../lib/opentdf-sdk-0.20.0.tgz", - "integrity": "sha512-9htqIO+bVhZF+mKJcoEf0LxOmIvYkjXu/giiuYMIvY2oLX+op7OG6qXTIup8/TpGMSb3iKIMosz7kNJ+8XSbSA==", + "integrity": "sha512-nQdpcnR4fq0xxp01cYAGtORzTJqG0/5ZWNZBJKNn2Kf53L/Im+Pnj6zwgUBQzpiaAkPdHP6PR/anXx7gqzWTJQ==", "license": "BSD-3-Clause-Clear", "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", + "@noble/post-quantum": "^0.6.1", "buffer-crc32": "^1.0.0", "jose": "6.0.8", "json-canonicalize": "^1.0.6", diff --git a/lib/package-lock.json b/lib/package-lock.json index 2e993e75f..e33f380b4 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", + "@noble/post-quantum": "^0.6.1", "buffer-crc32": "^1.0.0", "jose": "6.0.8", "json-canonicalize": "^1.0.6", @@ -1633,6 +1634,62 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, diff --git a/lib/package.json b/lib/package.json index 9a5a18b36..feffa2db7 100644 --- a/lib/package.json +++ b/lib/package.json @@ -90,6 +90,7 @@ "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", + "@noble/post-quantum": "^0.6.1", "buffer-crc32": "^1.0.0", "jose": "6.0.8", "json-canonicalize": "^1.0.6", diff --git a/lib/src/access.ts b/lib/src/access.ts index c3824ef01..29cb2fb66 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -92,6 +92,9 @@ export const rewrapAdditionalContextHeader = ( return base64.encode(JSON.stringify(context)); }; +// The supported key algorithms are defined in one place, `crypto/declarations.ts`. +// These public aliases preserve the historic `access.ts` API surface (name, tuple +// order, and guard behavior) while delegating to that single source of truth. export const PUBLIC_KEY_ALGORITHMS = KEY_ALGORITHMS; export type KasPublicKeyAlgorithm = KeyAlgorithm; @@ -142,6 +145,10 @@ export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => { return 'ES384'; case 'ec:secp521r1': return 'ES512'; + case 'mlkem:768': + return 'ML-KEM-768+A192KW'; + case 'mlkem:1024': + return 'ML-KEM-1024+A256KW'; default: throw new Error(`unsupported public key algorithm: ${a}`); } diff --git a/lib/src/crypto/pemPublicToCrypto.ts b/lib/src/crypto/pemPublicToCrypto.ts index 323ce4a6f..da278f45e 100644 --- a/lib/src/crypto/pemPublicToCrypto.ts +++ b/lib/src/crypto/pemPublicToCrypto.ts @@ -39,6 +39,9 @@ export const EC_OID = '06072a8648ce3d0201'; export const P256_OID = '06082a8648ce3d030107'; export const P384_OID = '06052b81040022'; export const P521_OID = '06052b81040023'; +// NIST CSRC OID arc 2.16.840.1.101.3.4.4.{2,3} = id-alg-ml-kem-{768,1024} +export const ML_KEM_768_OID = '0609608648016503040402'; +export const ML_KEM_1024_OID = '0609608648016503040403'; const SHA_512 = 'SHA-512'; const SPKI = 'spki'; const CERT_BEGIN = '-----BEGIN CERTIFICATE-----'; diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index ed67f9acd..93099ed3c 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -45,6 +45,7 @@ import { ConfigurationError } from '../../../src/errors.js'; import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js'; import { isEcKeyAlgorithm, + isMlKemKeyAlgorithm, isRsaKeyAlgorithm, type KeyPair, type SymmetricKey, @@ -729,7 +730,7 @@ export class Client { encryptionInformation.keyAccess = await Promise.all( splitPlan.map(async ({ kas, kid, pem, sid }) => { const algorithm = await algorithmFromPEM(pem, this.cryptoService); - if (algorithm !== wrappingKeyAlgorithm) { + if (wrappingKeyAlgorithm && algorithm !== wrappingKeyAlgorithm) { console.warn( `Mismatched wrapping key algorithm: [${algorithm}] is not requested type, [${wrappingKeyAlgorithm}]` ); @@ -739,6 +740,8 @@ export class Client { type = 'wrapped'; } else if (isEcKeyAlgorithm(algorithm)) { type = 'ec-wrapped'; + } else if (isMlKemKeyAlgorithm(algorithm)) { + type = 'mlkem-wrapped'; } else { throw new ConfigurationError(`Unsupported algorithm ${algorithm}`); } diff --git a/lib/tdf3/src/crypto/core/key-format.ts b/lib/tdf3/src/crypto/core/key-format.ts index 836bb0c13..901e309b7 100644 --- a/lib/tdf3/src/crypto/core/key-format.ts +++ b/lib/tdf3/src/crypto/core/key-format.ts @@ -1,10 +1,12 @@ import { ecAlgorithmToCurve, isEcKeyAlgorithm, + isMlKemKeyAlgorithm, isRsaKeyAlgorithm, type KeyAlgorithm, type KeyOptions, MIN_ASYMMETRIC_KEY_SIZE_BITS, + mlKemAlgorithmToLevel, type PrivateKey, type PublicKey, type PublicKeyInfo, @@ -17,11 +19,26 @@ import { exportSPKI, importX509 } from 'jose'; import { guessAlgorithmName, guessCurveName, + ML_KEM_768_OID, + ML_KEM_1024_OID, toJwsAlg, } from '../../../../src/crypto/pemPublicToCrypto.js'; -import { unwrapKey, wrapPrivateKey, wrapPublicKey } from './keys.js'; +import { + unwrapKey, + wrapMlKemPublicKey, + unwrapMlKemKey, + wrapPrivateKey, + wrapPublicKey, +} from './keys.js'; +import { decodeMlKemSpkiDer, encodeMlKemSpkiDer } from './mlkem-asn1.js'; import { rsaOaepSha1 } from './rsa.js'; +function detectMlKemLevelFromHex(hex: string): 768 | 1024 | undefined { + if (hex.includes(ML_KEM_768_OID)) return 768; + if (hex.includes(ML_KEM_1024_OID)) return 1024; + return undefined; +} + /** * Extract PEM public key from X.509 certificate or return PEM key as-is. */ @@ -115,6 +132,15 @@ export async function parsePublicKeyPem(pem: string): Promise { const keyData = base64Decode(removePemFormatting(publicKeyPem)); + // ML-KEM: detect by OID before falling through to RSA/EC. + // subtle.crypto does not (as of 2026) support ML-KEM + // decodeMlKemSpkiDer also validates length so this rejects mis-encoded keys. + const mlKemLevel = detectMlKemLevelFromHex(hexEncode(keyData)); + if (mlKemLevel !== undefined) { + decodeMlKemSpkiDer(new Uint8Array(keyData)); + return { algorithm: `mlkem:${mlKemLevel}` as const, pem: publicKeyPem }; + } + // Try RSA first - use JWK export to get modulus size try { const modulusBits = await extractRsaModulusBitLength(keyData); @@ -225,12 +251,31 @@ export async function publicKeyPemToJwk(publicKeyPem: string): Promise { const { usage = 'encrypt', extractable = true, algorithmHint } = options; - // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM. + // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM + // and identifies ML-KEM keys by OID. const keyInfo = await parsePublicKeyPem(pem); + + // ML-KEM: import via SPKI codec. WebCrypto has no ML-KEM support, so we keep + // the key as an opaque `PublicKey` carrying the raw encapsulation key bytes. + if (isMlKemKeyAlgorithm(keyInfo.algorithm)) { + const der = new Uint8Array(base64Decode(removePemFormatting(keyInfo.pem))); + const { level, rawKey } = decodeMlKemSpkiDer(der); + if (algorithmHint && algorithmHint !== `mlkem:${level}`) { + throw new ConfigurationError( + `ML-KEM SPKI advertises mlkem:${level} but algorithmHint is ${algorithmHint}` + ); + } + return wrapMlKemPublicKey(rawKey, level); + } + const algorithm = algorithmHint || keyInfo.algorithm; // Use keyInfo.pem (normalised SPKI) not the original pem, which may be a certificate. // Passing raw X.509 DER bytes to crypto.subtle.importKey('spki') would throw DataError. @@ -376,9 +421,21 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis } /** - * Export an opaque public key to PEM format. + * Export an opaque public key to PEM SPKI format. + * + * ML-KEM keys are wrapped in a SubjectPublicKeyInfo envelope using the NIST + * OIDs id-alg-ml-kem-{768,1024} (per draft-ietf-lamps-kyber-certificates), + * so the resulting PEM is byte-compatible with `openssl pkey -pubout`. */ export async function exportPublicKeyPem(key: PublicKey): Promise { + if (isMlKemKeyAlgorithm(key.algorithm)) { + const level = mlKemAlgorithmToLevel(key.algorithm); + const der = encodeMlKemSpkiDer(unwrapMlKemKey(key), level); + return formatAsPem( + der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength), + 'PUBLIC KEY' + ); + } const cryptoKey = unwrapKey(key); const keyBuffer = await crypto.subtle.exportKey('spki', cryptoKey); return formatAsPem(keyBuffer, 'PUBLIC KEY'); diff --git a/lib/tdf3/src/crypto/core/keys.ts b/lib/tdf3/src/crypto/core/keys.ts index 58da359e2..365083959 100644 --- a/lib/tdf3/src/crypto/core/keys.ts +++ b/lib/tdf3/src/crypto/core/keys.ts @@ -72,3 +72,37 @@ export function wrapSymmetricKey(keyBytes: Uint8Array): SymmetricKey { export function unwrapSymmetricKey(key: SymmetricKey): Uint8Array { return (key as any)._internal; } + +/** + * Wrap raw ML-KEM encapsulation key bytes as an opaque PublicKey. + * @internal + */ +export function wrapMlKemPublicKey(bytes: Uint8Array, level: 768 | 1024): PublicKey { + return { + _brand: 'PublicKey', + algorithm: `mlkem:${level}` as KeyAlgorithm, + mlKemLevel: level, + _internal: bytes, + } as unknown as PublicKey; +} + +/** + * Wrap raw ML-KEM decapsulation key bytes as an opaque PrivateKey. + * @internal + */ +export function wrapMlKemPrivateKey(bytes: Uint8Array, level: 768 | 1024): PrivateKey { + return { + _brand: 'PrivateKey', + algorithm: `mlkem:${level}` as KeyAlgorithm, + mlKemLevel: level, + _internal: bytes, + } as unknown as PrivateKey; +} + +/** + * Unwrap an opaque ML-KEM PublicKey or PrivateKey to get raw bytes. + * @internal + */ +export function unwrapMlKemKey(key: PublicKey | PrivateKey): Uint8Array { + return (key as any)._internal as Uint8Array; +} diff --git a/lib/tdf3/src/crypto/core/mlkem-asn1.ts b/lib/tdf3/src/crypto/core/mlkem-asn1.ts new file mode 100644 index 000000000..f202fa390 --- /dev/null +++ b/lib/tdf3/src/crypto/core/mlkem-asn1.ts @@ -0,0 +1,218 @@ +// ASN.1 SPKI codec for ML-KEM public keys per draft-ietf-lamps-kyber-certificates +// and NIST CSRC OIDs (2.16.840.1.101.3.4.4.{1,2,3}). +// +// SubjectPublicKeyInfo ::= SEQUENCE { +// algorithm AlgorithmIdentifier, -- OID only, no parameters +// subjectPublicKey BIT STRING -- raw ML-KEM encapsulation key bytes +// } + +const RAW_PUBLIC_KEY_SIZES: Record<768 | 1024, number> = { + 768: 1184, + 1024: 1568, +}; + +const OID_VARIANT_BYTE: Record<768 | 1024, number> = { 768: 0x02, 1024: 0x03 }; + +const LEVEL_FROM_VARIANT_BYTE: Record = { + 0x02: 768, + 0x03: 1024, +}; + +// First 8 bytes of the OID's BER-encoded contents (excluding tag/length and the +// final variant byte): 2.16.840.1.101.3.4.4 +const ML_KEM_OID_ARC_PREFIX = [0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x04]; + +function encodeLength(len: number): Uint8Array { + if (len < 0x80) return new Uint8Array([len]); + if (len < 0x100) return new Uint8Array([0x81, len]); + if (len < 0x10000) return new Uint8Array([0x82, (len >> 8) & 0xff, len & 0xff]); + throw new Error(`ASN.1 length too large for ML-KEM SPKI: ${len}`); +} + +function decodeLength( + bytes: Uint8Array, + offset: number +): { length: number; bytesConsumed: number } { + const first = bytes[offset]; + if (first < 0x80) return { length: first, bytesConsumed: 1 }; + const numOctets = first & 0x7f; + if (numOctets === 0 || numOctets > 3) { + throw new Error(`Unsupported ASN.1 length octets: ${numOctets}`); + } + let length = 0; + for (let i = 0; i < numOctets; i++) { + length = (length << 8) | bytes[offset + 1 + i]; + } + return { length, bytesConsumed: 1 + numOctets }; +} + +export function encodeMlKemSpkiDer(rawKey: Uint8Array, level: 768 | 1024): Uint8Array { + const expectedSize = RAW_PUBLIC_KEY_SIZES[level]; + if (rawKey.length !== expectedSize) { + throw new Error( + `ML-KEM-${level} raw public key must be ${expectedSize} bytes, got ${rawKey.length}` + ); + } + + // OID: 06 09 60 86 48 01 65 03 04 04 + const oidBytes = new Uint8Array([0x06, 0x09, ...ML_KEM_OID_ARC_PREFIX, OID_VARIANT_BYTE[level]]); + + // AlgorithmIdentifier ::= SEQUENCE { OID } (no parameters per FIPS 203) + const algIdLen = encodeLength(oidBytes.length); + const algId = new Uint8Array(1 + algIdLen.length + oidBytes.length); + algId[0] = 0x30; + algId.set(algIdLen, 1); + algId.set(oidBytes, 1 + algIdLen.length); + + // BIT STRING content: leading 0x00 (zero unused bits) || raw key + const bitStringContent = new Uint8Array(1 + rawKey.length); + bitStringContent[0] = 0x00; + bitStringContent.set(rawKey, 1); + const bitStringLen = encodeLength(bitStringContent.length); + const bitString = new Uint8Array(1 + bitStringLen.length + bitStringContent.length); + bitString[0] = 0x03; + bitString.set(bitStringLen, 1); + bitString.set(bitStringContent, 1 + bitStringLen.length); + + // Outer SubjectPublicKeyInfo SEQUENCE + const spkiContentLen = algId.length + bitString.length; + const spkiLen = encodeLength(spkiContentLen); + const spki = new Uint8Array(1 + spkiLen.length + spkiContentLen); + spki[0] = 0x30; + spki.set(spkiLen, 1); + spki.set(algId, 1 + spkiLen.length); + spki.set(bitString, 1 + spkiLen.length + algId.length); + return spki; +} + +export type MlKemSpkiDecoded = { level: 768 | 1024; rawKey: Uint8Array }; + +export function decodeMlKemSpkiDer(der: Uint8Array): MlKemSpkiDecoded { + if (der[0] !== 0x30) throw new Error('Invalid ML-KEM SPKI: missing outer SEQUENCE'); + let pos = 1; + const outer = decodeLength(der, pos); + pos += outer.bytesConsumed; + if (pos + outer.length !== der.length) { + throw new Error('Invalid ML-KEM SPKI: outer length does not match DER size'); + } + + if (der[pos] !== 0x30) throw new Error('Invalid ML-KEM SPKI: missing AlgorithmIdentifier'); + pos += 1; + const algId = decodeLength(der, pos); + pos += algId.bytesConsumed; + const algIdEnd = pos + algId.length; + + if (der[pos] !== 0x06) throw new Error('Invalid ML-KEM SPKI: missing OID'); + pos += 1; + const oid = decodeLength(der, pos); + pos += oid.bytesConsumed; + if (oid.length !== 9) { + throw new Error(`Invalid ML-KEM SPKI: OID length ${oid.length}, expected 9`); + } + for (let i = 0; i < ML_KEM_OID_ARC_PREFIX.length; i++) { + if (der[pos + i] !== ML_KEM_OID_ARC_PREFIX[i]) { + throw new Error('Invalid ML-KEM SPKI: OID is not in id-alg-ml-kem arc'); + } + } + const level = LEVEL_FROM_VARIANT_BYTE[der[pos + 8]]; + if (!level) { + throw new Error(`Invalid ML-KEM SPKI: unknown variant byte 0x${der[pos + 8].toString(16)}`); + } + pos += oid.length; + if (pos !== algIdEnd) { + throw new Error('Invalid ML-KEM SPKI: extra data inside AlgorithmIdentifier'); + } + + if (der[pos] !== 0x03) throw new Error('Invalid ML-KEM SPKI: missing BIT STRING'); + pos += 1; + const bs = decodeLength(der, pos); + pos += bs.bytesConsumed; + // Reject truncated contents: the declared BIT STRING must fit exactly within + // der, otherwise der.slice() below would silently return a short rawKey. + if (bs.length < 1 || pos + bs.length !== der.length) { + throw new Error('Invalid ML-KEM SPKI: BIT STRING length does not match DER size'); + } + if (der[pos] !== 0x00) throw new Error('Invalid ML-KEM SPKI: BIT STRING unused-bits must be 0'); + pos += 1; + + const rawKeyLen = bs.length - 1; + const expectedSize = RAW_PUBLIC_KEY_SIZES[level]; + if (rawKeyLen !== expectedSize) { + throw new Error( + `Invalid ML-KEM SPKI: raw key length ${rawKeyLen} does not match ML-KEM-${level} (${expectedSize})` + ); + } + return { level, rawKey: der.slice(pos, pos + rawKeyLen) }; +} + +export function isMlKemSpkiDer(der: Uint8Array): boolean { + try { + decodeMlKemSpkiDer(der); + return true; + } catch { + return false; + } +} + +// kemEnvelope ::= SEQUENCE { +// kemCiphertext [0] IMPLICIT OCTET STRING, +// encryptedDek [1] IMPLICIT OCTET STRING +// } +// Matches opentdf/platform lib/ocrypto `kemEnvelope`, the canonical ML-KEM +// "direct key wrap" container: the KEM ciphertext plus the DEK sealed with +// AES-256-GCM, where the AES key is the raw 32-byte ML-KEM shared secret (no +// HKDF) and the 12-byte GCM nonce is prepended to the ciphertext+tag. + +function encodeTaggedOctetString(tag: number, content: Uint8Array): Uint8Array { + const len = encodeLength(content.length); + const out = new Uint8Array(1 + len.length + content.length); + out[0] = tag; + out.set(len, 1); + out.set(content, 1 + len.length); + return out; +} + +export function encodeKemEnvelopeDer( + kemCiphertext: Uint8Array, + encryptedDek: Uint8Array +): Uint8Array { + const field0 = encodeTaggedOctetString(0x80, kemCiphertext); // [0] IMPLICIT OCTET STRING + const field1 = encodeTaggedOctetString(0x81, encryptedDek); // [1] IMPLICIT OCTET STRING + const contentLen = field0.length + field1.length; + const seqLen = encodeLength(contentLen); + const seq = new Uint8Array(1 + seqLen.length + contentLen); + seq[0] = 0x30; + seq.set(seqLen, 1); + seq.set(field0, 1 + seqLen.length); + seq.set(field1, 1 + seqLen.length + field0.length); + return seq; +} + +export type KemEnvelopeDecoded = { kemCiphertext: Uint8Array; encryptedDek: Uint8Array }; + +export function decodeKemEnvelopeDer(der: Uint8Array): KemEnvelopeDecoded { + if (der[0] !== 0x30) throw new Error('Invalid ML-KEM envelope: missing outer SEQUENCE'); + let pos = 1; + const outer = decodeLength(der, pos); + pos += outer.bytesConsumed; + if (pos + outer.length !== der.length) { + throw new Error('Invalid ML-KEM envelope: outer length does not match DER size'); + } + const readField = (tag: number, name: string): Uint8Array => { + if (der[pos] !== tag) { + throw new Error(`Invalid ML-KEM envelope: missing ${name}`); + } + pos += 1; + const f = decodeLength(der, pos); + pos += f.bytesConsumed; + const value = der.slice(pos, pos + f.length); + pos += f.length; + return value; + }; + const kemCiphertext = readField(0x80, 'KEM ciphertext'); + const encryptedDek = readField(0x81, 'encrypted DEK'); + if (pos !== der.length) { + throw new Error('Invalid ML-KEM envelope: trailing data after fields'); + } + return { kemCiphertext, encryptedDek }; +} diff --git a/lib/tdf3/src/crypto/core/mlkem.ts b/lib/tdf3/src/crypto/core/mlkem.ts new file mode 100644 index 000000000..1d8b2a542 --- /dev/null +++ b/lib/tdf3/src/crypto/core/mlkem.ts @@ -0,0 +1,89 @@ +import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'; +import { + type HkdfParams, + type KeyPair, + type PrivateKey, + type PublicKey, + type SymmetricKey, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { + unwrapMlKemKey, + wrapMlKemPrivateKey, + wrapMlKemPublicKey, + wrapSymmetricKey, + unwrapSymmetricKey, +} from './keys.js'; + +const MLKEM: Record<768 | 1024, typeof ml_kem768 | typeof ml_kem1024> = { + 768: ml_kem768, + 1024: ml_kem1024, +} as const; + +/** Ciphertext byte lengths per ML-KEM level (FIPS 203 Table 3). */ +export const MLKEM_CT_SIZES: Record<768 | 1024, number> = { + 768: 1088, + 1024: 1568, +}; + +function assertMlKemLevel(key: PublicKey | PrivateKey): 768 | 1024 { + const level = key.mlKemLevel; + if (level !== 768 && level !== 1024) { + throw new ConfigurationError(`ML-KEM key is missing a valid mlKemLevel (got ${level})`); + } + return level; +} + +export async function generateMlKemKeyPair(level: 768 | 1024): Promise { + const { publicKey, secretKey } = MLKEM[level].keygen(); + return { + publicKey: wrapMlKemPublicKey(publicKey, level), + privateKey: wrapMlKemPrivateKey(secretKey, level), + }; +} + +export async function mlKemEncapsulate( + pk: PublicKey +): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }> { + const level = assertMlKemLevel(pk); + const ekBytes = unwrapMlKemKey(pk); + const { cipherText, sharedSecret } = MLKEM[level].encapsulate(ekBytes); + return { + ciphertext: cipherText, + sharedSecret: wrapSymmetricKey(sharedSecret), + }; +} + +export async function mlKemDecapsulate(sk: PrivateKey, ct: Uint8Array): Promise { + const level = assertMlKemLevel(sk); + const dkBytes = unwrapMlKemKey(sk); + const sharedSecret = MLKEM[level].decapsulate(ct, dkBytes); + return wrapSymmetricKey(sharedSecret); +} + +/** + * Derive a 256-bit AES-GCM key from raw input key material via HKDF. + * Used to convert an ML-KEM shared secret into a usable AES key. + */ +export async function hkdfDerive(ikm: SymmetricKey, params: HkdfParams): Promise { + const ikmBytes = unwrapSymmetricKey(ikm); + + const hkdfKey = await crypto.subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveKey']); + + const keyLength = params.keyLength ?? 256; + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: params.hash, + salt: params.salt, + info: params.info ?? new Uint8Array(0), + }, + hkdfKey, + { name: 'AES-GCM', length: keyLength }, + true, + ['encrypt', 'decrypt'] + ); + + const keyBytes = await crypto.subtle.exportKey('raw', derivedKey); + return wrapSymmetricKey(new Uint8Array(keyBytes)); +} diff --git a/lib/tdf3/src/crypto/declarations.ts b/lib/tdf3/src/crypto/declarations.ts index 84c3c4307..c26365f41 100644 --- a/lib/tdf3/src/crypto/declarations.ts +++ b/lib/tdf3/src/crypto/declarations.ts @@ -21,27 +21,56 @@ export type PemKeyPair = { privateKey: string; }; +/** + * Supported key algorithms, grouped by key-mechanism family. These `as const` + * arrays are the single source of truth: the family subtypes, the combined + * {@link KeyAlgorithm} union, and the runtime guards below all derive from them. + */ export const EC_KEY_ALGORITHMS = ['ec:secp256r1', 'ec:secp384r1', 'ec:secp521r1'] as const; export const RSA_KEY_ALGORITHMS = ['rsa:2048', 'rsa:4096'] as const; +export const MLKEM_KEY_ALGORITHMS = ['mlkem:768', 'mlkem:1024'] as const; -/** Order is significant: re-exported as `PUBLIC_KEY_ALGORITHMS` in `access.ts` and consumed as an ordered list (e.g. CLI `--choices` output). */ -export const KEY_ALGORITHMS = [...EC_KEY_ALGORITHMS, ...RSA_KEY_ALGORITHMS] as const; +/** + * All supported key algorithms. Order is significant: it is re-exported as + * `PUBLIC_KEY_ALGORITHMS` from `access.ts`, which historically listed EC, then + * RSA, then ML-KEM. + */ +export const KEY_ALGORITHMS = [ + ...EC_KEY_ALGORITHMS, + ...RSA_KEY_ALGORITHMS, + ...MLKEM_KEY_ALGORITHMS, +] as const; +/** Elliptic-curve key algorithm identifiers (`ec:*`). */ export type EcKeyAlgorithm = (typeof EC_KEY_ALGORITHMS)[number]; +/** RSA key algorithm identifiers (`rsa:*`). */ export type RsaKeyAlgorithm = (typeof RSA_KEY_ALGORITHMS)[number]; +/** ML-KEM key algorithm identifiers (`mlkem:*`). */ +export type MlKemKeyAlgorithm = (typeof MLKEM_KEY_ALGORITHMS)[number]; /** * Key algorithm identifier combining key type and parameters. */ -export type KeyAlgorithm = EcKeyAlgorithm | RsaKeyAlgorithm; +export type KeyAlgorithm = EcKeyAlgorithm | RsaKeyAlgorithm | MlKemKeyAlgorithm; +/** Narrows a string to an elliptic-curve (`ec:*`) key algorithm. */ export const isEcKeyAlgorithm = (a: string): a is EcKeyAlgorithm => (EC_KEY_ALGORITHMS as readonly string[]).includes(a); +/** Narrows a string to an RSA (`rsa:*`) key algorithm. */ export const isRsaKeyAlgorithm = (a: string): a is RsaKeyAlgorithm => (RSA_KEY_ALGORITHMS as readonly string[]).includes(a); +/** Narrows a string to an ML-KEM (`mlkem:*`) key algorithm. */ +export const isMlKemKeyAlgorithm = (a: string): a is MlKemKeyAlgorithm => + (MLKEM_KEY_ALGORITHMS as readonly string[]).includes(a); +/** Narrows a string to any supported key algorithm. */ export const isKeyAlgorithm = (a: string): a is KeyAlgorithm => (KEY_ALGORITHMS as readonly string[]).includes(a); +// Strictly-typed accessors for the variant encoded in each family's algorithm +// literal. The `Record` maps are exhaustive by construction — adding +// a family member or mistyping a key is a compile error — so these avoid the +// `parseInt`/`split(':')`/`as` patterns they replace. + const EC_ALGORITHM_CURVES: Record = { 'ec:secp256r1': 'P-256', 'ec:secp384r1': 'P-384', @@ -58,6 +87,14 @@ const RSA_ALGORITHM_MODULUS_BITS: Record = { export const rsaAlgorithmToModulusBits = (alg: RsaKeyAlgorithm): 2048 | 4096 => RSA_ALGORITHM_MODULUS_BITS[alg]; +const MLKEM_ALGORITHM_LEVELS: Record = { + 'mlkem:768': 768, + 'mlkem:1024': 1024, +}; +/** The NIST security level for an `mlkem:*` key algorithm. */ +export const mlKemAlgorithmToLevel = (alg: MlKemKeyAlgorithm): 768 | 1024 => + MLKEM_ALGORITHM_LEVELS[alg]; + /** * Options for key generation and import. */ @@ -95,6 +132,8 @@ export type PublicKey = { readonly modulusBits?: number; /** EC curve name (only for EC keys) */ readonly curve?: ECCurve; + /** ML-KEM security level (only for mlkem:* keys) */ + readonly mlKemLevel?: 768 | 1024; }; /** @@ -111,6 +150,8 @@ export type PrivateKey = { readonly modulusBits?: number; /** EC curve name (only for EC keys) */ readonly curve?: ECCurve; + /** ML-KEM security level (only for mlkem:* keys) */ + readonly mlKemLevel?: 768 | 1024; }; /** @@ -184,7 +225,7 @@ export type HkdfParams = { export type PublicKeyInfo = { /** Detected algorithm of the key. */ algorithm: KeyAlgorithm; - /** Normalized PEM string. */ + /** Normalized PEM string (or raw base64 for ML-KEM keys). */ pem: string; }; @@ -429,4 +470,38 @@ export type CryptoService = { * @throws ConfigurationError if not supported by the implementation */ mergeSymmetricKeys: (shares: SymmetricKey[]) => Promise; + + /** + * Generate an ML-KEM key pair (NIST FIPS 203). + * @param level - Security level: 768 or 1024 + * @returns Opaque key pair; publicKey carries the encapsulation key bytes, privateKey the decapsulation key bytes + */ + generateMlKemKeyPair: (level: 768 | 1024) => Promise; + + /** + * Encapsulate a shared secret to an ML-KEM public key. + * @param pk - Opaque ML-KEM public key (encapsulation key) + * @returns KEM ciphertext and raw shared secret (32 bytes, not yet HKDF-derived) + */ + mlKemEncapsulate: ( + pk: PublicKey + ) => Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }>; + + /** + * Decapsulate an ML-KEM ciphertext with the private key. + * Relies on @noble/post-quantum implicit rejection on failure (FIPS 203 mandate). + * @param sk - Opaque ML-KEM private key (decapsulation key) + * @param ct - KEM ciphertext bytes + * @returns Raw shared secret (32 bytes, not yet HKDF-derived) + */ + mlKemDecapsulate: (sk: PrivateKey, ct: Uint8Array) => Promise; + + /** + * Derive a symmetric key from input key material using HKDF. + * Used to derive an AES-256-GCM key from an ML-KEM shared secret. + * @param ikm - Input key material (e.g., ML-KEM shared secret) + * @param params - HKDF parameters (hash, salt, optional info/keyLength) + * @returns Opaque 256-bit AES symmetric key + */ + hkdfDerive: (ikm: SymmetricKey, params: HkdfParams) => Promise; }; diff --git a/lib/tdf3/src/crypto/index.ts b/lib/tdf3/src/crypto/index.ts index cb24fa03e..7e4ddd4e6 100644 --- a/lib/tdf3/src/crypto/index.ts +++ b/lib/tdf3/src/crypto/index.ts @@ -29,6 +29,12 @@ import { rsaPkcs1Sha256, } from './core/rsa.js'; import { deriveKeyFromECDH, generateECKeyPair } from './core/ec.js'; +import { + generateMlKemKeyPair, + hkdfDerive, + mlKemDecapsulate, + mlKemEncapsulate, +} from './core/mlkem.js'; import { sign, verify } from './core/signing.js'; import { exportPrivateKeyPem, @@ -78,7 +84,9 @@ export { generateECKeyPair, generateKey, generateKeyPair, + generateMlKemKeyPair, generateSigningKeyPair, + hkdfDerive, hex2Ab, hmac, importPrivateKey, @@ -86,6 +94,8 @@ export { importSymmetricKey, jwkToPublicKeyPem, mergeSymmetricKeys, + mlKemDecapsulate, + mlKemEncapsulate, parsePublicKeyPem, publicKeyPemToJwk, randomBytes, @@ -113,12 +123,16 @@ export const DefaultCryptoService: CryptoService = { generateECKeyPair, generateKey, generateKeyPair, + generateMlKemKeyPair, generateSigningKeyPair, + hkdfDerive, importPrivateKey, importPublicKey, importSymmetricKey, jwkToPublicKeyPem, mergeSymmetricKeys, + mlKemDecapsulate, + mlKemEncapsulate, parsePublicKeyPem, randomBytes, hmac, diff --git a/lib/tdf3/src/models/key-access.ts b/lib/tdf3/src/models/key-access.ts index 6be30317a..760b3b5c8 100644 --- a/lib/tdf3/src/models/key-access.ts +++ b/lib/tdf3/src/models/key-access.ts @@ -1,11 +1,20 @@ import { base64, hex } from '../../../src/encodings/index.js'; +import { ConfigurationError } from '../../../src/errors.js'; import { Binary } from '../binary.js'; -import type { CryptoService, KeyPair, SymmetricKey } from '../crypto/declarations.js'; +import type { + CryptoService, + KeyPair, + MlKemKeyAlgorithm, + SymmetricKey, +} from '../crypto/declarations.js'; +import { mlKemAlgorithmToLevel } from '../crypto/declarations.js'; import { getZtdfSalt } from '../crypto/salt.js'; import { Algorithms } from '../ciphers/index.js'; import { Policy } from './policy.js'; +import { MLKEM_CT_SIZES } from '../crypto/core/mlkem.js'; +import { encodeKemEnvelopeDer } from '../crypto/core/mlkem-asn1.js'; -export type KeyAccessType = 'remote' | 'wrapped' | 'ec-wrapped'; +export type KeyAccessType = 'remote' | 'wrapped' | 'ec-wrapped' | 'mlkem-wrapped'; export const schemaVersion = '1.0'; @@ -152,7 +161,96 @@ export class Wrapped { } } -export type KeyAccess = ECWrapped | Wrapped; +export class MlKemWrapped { + readonly type = 'mlkem-wrapped'; + readonly level: 768 | 1024; + keyAccessObject?: KeyAccessObject; + + constructor( + public readonly url: string, + public readonly kid: string, + public readonly publicKey: string, + public readonly metadata: unknown, + public readonly cryptoService: CryptoService, + public readonly sid: string | undefined, + public readonly alg: MlKemKeyAlgorithm + ) { + if (!kid?.trim()) { + throw new ConfigurationError('MlKemWrapped requires a non-empty kid'); + } + this.level = mlKemAlgorithmToLevel(alg); + } + + async write( + policy: Policy, + dek: SymmetricKey, + encryptedMetadataStr: string + ): Promise { + const policyStr = JSON.stringify(policy); + + // Import KAS ML-KEM encapsulation key from raw base64 + const kasPublicKey = await this.cryptoService.importPublicKey(this.publicKey, { + algorithmHint: this.alg, + }); + + // ML-KEM encapsulate → KEM ciphertext + raw shared secret + const { ciphertext: kemCiphertext, sharedSecret } = + await this.cryptoService.mlKemEncapsulate(kasPublicKey); + + // ML-KEM "direct key wrap" (per the platform's canonical format): the raw + // 32-byte ML-KEM shared secret IS the AES-256 key — no HKDF. AES-256-GCM + // seals the DEK, and the 12-byte nonce is prepended to the ciphertext+tag. + const iv = await this.cryptoService.randomBytes(12); + const encryptResult = await this.cryptoService.encrypt( + dek, + sharedSecret, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + + const aesCt = new Uint8Array(encryptResult.payload.asArrayBuffer()); + const authTag = encryptResult.authTag + ? new Uint8Array(encryptResult.authTag.asArrayBuffer()) + : new Uint8Array(0); + + // encryptedDek = nonce(12) || aes_ct || tag(16) (nonce-prepended AES-GCM) + const encryptedDek = new Uint8Array(iv.length + aesCt.length + authTag.length); + encryptedDek.set(iv); + encryptedDek.set(aesCt, iv.length); + encryptedDek.set(authTag, iv.length + aesCt.length); + + // wrappedKey = base64( DER( kemEnvelope { [0] kemCiphertext, [1] encryptedDek } ) ) + const blob = encodeKemEnvelopeDer(kemCiphertext, encryptedDek); + + const policyBinding = hex.encodeArrayBuffer( + (await this.cryptoService.hmac(new TextEncoder().encode(base64.encode(policyStr)), dek)) + .buffer + ); + + const kao: KeyAccessObject = { + type: 'mlkem-wrapped', + url: this.url, + protocol: 'kas', + wrappedKey: base64.encodeArrayBuffer(blob), + encryptedMetadata: base64.encode(encryptedMetadataStr), + policyBinding: { + alg: 'HS256', + hash: base64.encode(policyBinding), + }, + schemaVersion, + }; + kao.kid = this.kid; + if (this.sid?.length) { + kao.sid = this.sid; + } + this.keyAccessObject = kao; + return kao; + } +} + +export { MLKEM_CT_SIZES }; + +export type KeyAccess = ECWrapped | MlKemWrapped | Wrapped; /** * A KeyAccess object stores all information about how an object key OR one key split is stored. diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index c96faedb1..5e2a9dadd 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -40,7 +40,9 @@ import { DecoratedReadableStream } from './client/DecoratedReadableStream.js'; import { type CryptoService, type DecryptResult, + isMlKemKeyAlgorithm, type KeyPair, + mlKemAlgorithmToLevel, type SymmetricKey, } from './crypto/declarations.js'; import { Algorithms } from './ciphers/index.js'; @@ -48,6 +50,8 @@ import { ECWrapped, KeyAccessType, KeyInfo, + MLKEM_CT_SIZES, + MlKemWrapped, Manifest, Policy, SplitKey, @@ -60,6 +64,7 @@ import { unsigned } from './utils/buffer-crc32.js'; import { ZipReader, ZipWriter, concatUint8, buffToString } from './utils/index.js'; import { CentralDirectory } from './utils/zip-reader.js'; import { getZtdfSalt } from './crypto/salt.js'; +import { decodeKemEnvelopeDer } from './crypto/core/mlkem-asn1.js'; import { Payload } from './models/payload.js'; import { getRequiredObligationFQNs, @@ -283,6 +288,18 @@ export async function buildKeyAccess({ return new Wrapped(url, kid, pubKey, metadata, cryptoService, sid); case 'ec-wrapped': return new ECWrapped(url, kid, pubKey, metadata, cryptoService, sid); + case 'mlkem-wrapped': + if (!isMlKemKeyAlgorithm(alg)) { + throw new ConfigurationError( + `buildKeyAccess: algorithm [${alg}] is not valid for mlkem-wrapped` + ); + } + if (!kid?.trim()) { + throw new ConfigurationError( + `buildKeyAccess: kid is required for ML-KEM algorithm [${alg}]` + ); + } + return new MlKemWrapped(url, kid, pubKey, metadata, cryptoService, sid, alg); default: throw new ConfigurationError(`buildKeyAccess: Key access type [${type}] is unsupported`); } @@ -706,11 +723,11 @@ export async function loadTDFStream(chunker: Chunker): Promise { +): Record> { const allowed = (k: KeyAccessObject) => allowedKases.allows(k.url); const splitIds = new Set(keyAccess.map(({ sid }) => sid ?? '')); - const accessibleSplits = new Set(keyAccess.filter(allowed).map(({ sid }) => sid ?? '')); + const accessibleSplits = new Set(keyAccess.filter(allowed).map(({ sid }) => sid)); if (splitIds.size > accessibleSplits.size) { const disallowedKases = new Set(keyAccess.filter((k) => !allowed(k)).map(({ url }) => url)); throw new UnsafeUrlError( @@ -720,15 +737,23 @@ export function splitLookupTableFactory( ...disallowedKases ); } - // Each split id maps to the list of KAOs that can unwrap it (in a disjunction, - // any one succeeding unwraps the split). Note: a KAS may appear several times - // for the same split, possibly using different keys to encrypt the same split value. - const splitPotentials: Record = Object.fromEntries( - [...splitIds].map((s) => [s, []]) + const splitPotentials: Record> = Object.fromEntries( + [...splitIds].map((s) => [s, {}]) ); for (const kao of keyAccess) { + const disjunction = splitPotentials[kao.sid ?? '']; + if (kao.url in disjunction) { + // TODO(DSPX-3454): Handle duplicate KAS URLs with different KIDs. + // Each KAO contains a KID - the function should be updated to use this + // information to differentiate between keys from the same KAS. + // Cross-SDK validation needed via xtest. + throw new InvalidFileError( + `Unable to decrypt: Multiple keys detected for Key Access Server [${kao.url}]. ` + + `Please contact your administrator.` + ); + } if (allowed(kao)) { - splitPotentials[kao.sid ?? ''].push(kao); + disjunction[kao.url] = kao; } } return splitPotentials; @@ -776,6 +801,9 @@ async function unwrapKey({ } else if (wrappingKeyAlgorithm === 'rsa:2048' || !wrappingKeyAlgorithm) { // generateKeyPair() returns opaque keys ephemeralEncryptionKeys = await cryptoService.generateKeyPair(); + } else if (isMlKemKeyAlgorithm(wrappingKeyAlgorithm)) { + const level = mlKemAlgorithmToLevel(wrappingKeyAlgorithm); + ephemeralEncryptionKeys = await cryptoService.generateMlKemKeyPair(level); } else { throw new ConfigurationError(`Unsupported wrapping key algorithm [${wrappingKeyAlgorithm}]`); } @@ -889,6 +917,49 @@ async function unwrapKey({ requiredObligations, }; } + + if (wrappingKeyAlgorithm && isMlKemKeyAlgorithm(wrappingKeyAlgorithm)) { + // The KAS rewrap response uses the platform's canonical ML-KEM + // "direct key wrap" container: + // DER( kemEnvelope { [0] kemCiphertext, [1] encryptedDek } ) + // where encryptedDek = nonce(12) || aes_ct || tag(16) and the AES-256 + // key is the raw ML-KEM shared secret (no HKDF). + const level = mlKemAlgorithmToLevel(wrappingKeyAlgorithm); + const { kemCiphertext, encryptedDek } = decodeKemEnvelopeDer(entityWrappedKey); + const expectedCtLen = MLKEM_CT_SIZES[level]; + if (kemCiphertext.length !== expectedCtLen) { + throw new DecryptError( + `malformed ML-KEM wrapped key for ${wrappingKeyAlgorithm}: KEM ciphertext is ${kemCiphertext.length} bytes, expected ${expectedCtLen}` + ); + } + // encryptedDek must hold at least a 12B nonce and a 16B GCM tag. + if (encryptedDek.length < 12 + 16) { + throw new DecryptError( + `malformed ML-KEM wrapped key for ${wrappingKeyAlgorithm}: encrypted DEK is only ${encryptedDek.length} bytes` + ); + } + const iv = encryptedDek.slice(0, 12); + const wrappedKey = encryptedDek.slice(12); + + const sharedSecret = await cryptoService.mlKemDecapsulate( + ephemeralEncryptionKeys.privateKey, + kemCiphertext + ); + + const decryptResult = await cryptoService.decrypt( + Binary.fromArrayBuffer(wrappedKey.buffer), + sharedSecret, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + + return { + key: new Uint8Array(decryptResult.payload.asArrayBuffer()), + metadata, + requiredObligations, + }; + } + const key = Binary.fromArrayBuffer(entityWrappedKey); const decryptedKeyBinary = await cryptoService.decryptWithPrivateKey( key, @@ -923,26 +994,22 @@ async function unwrapKey({ const splitPromises: Record Promise> = {}; for (const splitId of Object.keys(splitPotentials)) { const potentials = splitPotentials[splitId]; - if (!potentials?.length) { + if (!potentials || !Object.keys(potentials).length) { throw new UnsafeUrlError( `Unreconstructable key - no valid KAS found for split ${JSON.stringify(splitId)}`, '' ); } const anyPromises: Record Promise> = {}; - potentials.forEach((keySplitInfo, i) => { - // Key by url+kid+index so multiple KAOs on the same KAS stay distinct - // alternatives within the split's disjunction (anyPool tries each until - // one succeeds). - const alternativeKey = `${keySplitInfo.url}#${keySplitInfo.kid ?? ''}#${i}`; - anyPromises[alternativeKey] = async () => { + for (const [kas, keySplitInfo] of Object.entries(potentials)) { + anyPromises[kas] = async () => { try { return await tryKasRewrap(keySplitInfo); } catch (e) { throw handleRewrapError(e as Error); } }; - }); + } splitPromises[splitId] = () => anyPool(poolSize, anyPromises); } try { diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 9d4806277..1c79035bf 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -245,8 +245,18 @@ describe('encrypt decrypt test', async function () { const expectedVal = 'hello world'; const kasUrl = `http://localhost:3000`; - for (const encapKeyType of ['ec:secp256r1', 'rsa:2048'] as KasPublicKeyAlgorithm[]) { - for (const rewrapKeyType of ['ec:secp256r1', 'rsa:2048'] as KasPublicKeyAlgorithm[]) { + for (const encapKeyType of [ + 'ec:secp256r1', + 'rsa:2048', + 'mlkem:768', + 'mlkem:1024', + ] as KasPublicKeyAlgorithm[]) { + for (const rewrapKeyType of [ + 'ec:secp256r1', + 'rsa:2048', + 'mlkem:768', + 'mlkem:1024', + ] as KasPublicKeyAlgorithm[]) { it(`encrypt-decrypt stream source happy path {encap: ${encapKeyType}, rewrap: ${rewrapKeyType}}`, async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); @@ -363,58 +373,6 @@ describe('encrypt decrypt test', async function () { } } - it('decrypts when the same KAS wraps the same split twice (DSPX-3379)', async function () { - const cipher = new AesGcmCipher(WebCryptoService); - const encryptionInformation = new SplitKey(cipher); - const key1 = await encryptionInformation.generateKey(); - const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); - - const client = new Client.Client({ - kasEndpoint: kasUrl, - platformUrl: kasUrl, - allowedKases: [kasUrl], - dpopKeys: Mocks.entityKeyPair(), - clientId: 'id', - authProvider, - }); - - const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; - - // Two KAOs pointing at the same KAS for the same split id: the same KAS - // wraps the same split twice. Previously this threw; now the copies are - // disjunction alternatives and the file must still decrypt. - const encryptedStream = await client.encrypt({ - metadata: Mocks.getMetadataObject(), - wrappingKeyAlgorithm: 'rsa:2048', - offline: true, - scope, - keyMiddleware, - splitPlan: [ - { kas: kasUrl, sid: '1' }, - { kas: kasUrl, sid: '1' }, - ], - source: new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(expectedVal)); - controller.close(); - }, - }), - }); - - const kaos = encryptedStream.manifest.encryptionInformation.keyAccess; - assert.equal(kaos.length, 2, 'expected two KAOs for the duplicated split'); - assert.equal(kaos[0].url, kaos[1].url); - assert.equal(kaos[0].sid, kaos[1].sid); - - const decryptStream = await client.decrypt({ - source: { type: 'stream', location: encryptedStream.stream }, - wrappingKeyAlgorithm: 'rsa:2048', - }); - - const { value: decryptedText } = await decryptStream.stream.getReader().read(); - assert.equal(new TextDecoder().decode(decryptedText), expectedVal); - }); - it('encrypt-decrypt with system metadata assertion', async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); diff --git a/lib/tests/mocha/unit/crypto-di.spec.ts b/lib/tests/mocha/unit/crypto-di.spec.ts index 3911d8884..635cf78ff 100644 --- a/lib/tests/mocha/unit/crypto-di.spec.ts +++ b/lib/tests/mocha/unit/crypto-di.spec.ts @@ -135,6 +135,20 @@ describe('CryptoService DI', () => { mergeSymmetricKeys: function (shares: SymmetricKey[]): Promise { throw new Error(NOT_IMPLEMENTED); }, + generateMlKemKeyPair: function (level: 768 | 1024): Promise { + throw new Error(NOT_IMPLEMENTED); + }, + mlKemEncapsulate: function ( + pk: PublicKey + ): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }> { + throw new Error(NOT_IMPLEMENTED); + }, + mlKemDecapsulate: function (sk: PrivateKey, ct: Uint8Array): Promise { + throw new Error(NOT_IMPLEMENTED); + }, + hkdfDerive: function (ikm: SymmetricKey, params: HkdfParams): Promise { + throw new Error(NOT_IMPLEMENTED); + }, }; const mockAuthProvider: AuthProvider = { @@ -304,6 +318,20 @@ describe('CryptoService DI', () => { mergeSymmetricKeys: function (shares: SymmetricKey[]): Promise { throw new Error(NOT_IMPLEMENTED); }, + generateMlKemKeyPair: function (level: 768 | 1024): Promise { + throw new Error(NOT_IMPLEMENTED); + }, + mlKemEncapsulate: function ( + pk: PublicKey + ): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }> { + throw new Error(NOT_IMPLEMENTED); + }, + mlKemDecapsulate: function (sk: PrivateKey, ct: Uint8Array): Promise { + throw new Error(NOT_IMPLEMENTED); + }, + hkdfDerive: function (ikm: SymmetricKey, params: HkdfParams): Promise { + throw new Error(NOT_IMPLEMENTED); + }, }; const mockAuthProvider: AuthProvider = { diff --git a/lib/tests/mocha/unit/crypto/mlkem.spec.ts b/lib/tests/mocha/unit/crypto/mlkem.spec.ts new file mode 100644 index 000000000..48609b5bd --- /dev/null +++ b/lib/tests/mocha/unit/crypto/mlkem.spec.ts @@ -0,0 +1,150 @@ +import { expect } from 'chai'; +import { + generateMlKemKeyPair, + mlKemEncapsulate, + mlKemDecapsulate, + hkdfDerive, +} from '../../../../tdf3/src/crypto/core/mlkem.js'; +import { unwrapMlKemKey } from '../../../../tdf3/src/crypto/core/keys.js'; +import { unwrapSymmetricKey } from '../../../../tdf3/src/crypto/core/keys.js'; +import { isPublicKeyAlgorithm, publicKeyAlgorithmToJwa } from '../../../../src/access.js'; +import { + importPublicKey, + exportPublicKeyPem, + parsePublicKeyPem, +} from '../../../../tdf3/src/crypto/core/key-format.js'; +import { + decodeMlKemSpkiDer, + encodeMlKemSpkiDer, +} from '../../../../tdf3/src/crypto/core/mlkem-asn1.js'; + +describe('ML-KEM crypto', () => { + for (const level of [768, 1024] as const) { + describe(`ML-KEM-${level}`, () => { + it('generateMlKemKeyPair produces correctly-sized keys', async () => { + const { publicKey, privateKey } = await generateMlKemKeyPair(level); + expect(publicKey.algorithm).to.equal(`mlkem:${level}`); + expect(publicKey.mlKemLevel).to.equal(level); + expect(privateKey.algorithm).to.equal(`mlkem:${level}`); + expect(privateKey.mlKemLevel).to.equal(level); + + const pkBytes = unwrapMlKemKey(publicKey); + const skBytes = unwrapMlKemKey(privateKey); + const expectedPkSizes = { 768: 1184, 1024: 1568 }; + const expectedSkSizes = { 768: 2400, 1024: 3168 }; + expect(pkBytes.length).to.equal(expectedPkSizes[level]); + expect(skBytes.length).to.equal(expectedSkSizes[level]); + }); + + it('encapsulate/decapsulate round-trip recovers shared secret', async () => { + const { publicKey, privateKey } = await generateMlKemKeyPair(level); + const { ciphertext, sharedSecret: ss1 } = await mlKemEncapsulate(publicKey); + const ss2 = await mlKemDecapsulate(privateKey, ciphertext); + + const ss1Bytes = unwrapSymmetricKey(ss1); + const ss2Bytes = unwrapSymmetricKey(ss2); + expect(ss1Bytes).to.deep.equal(ss2Bytes); + expect(ss1Bytes.length).to.equal(32); + }); + + it('exportPublicKeyPem / importPublicKey round-trip via PEM SPKI', async () => { + const { publicKey } = await generateMlKemKeyPair(level); + const pem = await exportPublicKeyPem(publicKey); + expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); + expect(pem).to.include('-----END PUBLIC KEY-----'); + + const reimported = await importPublicKey(pem, { algorithmHint: `mlkem:${level}` }); + expect(reimported.algorithm).to.equal(`mlkem:${level}`); + expect(unwrapMlKemKey(reimported)).to.deep.equal(unwrapMlKemKey(publicKey)); + + // parsePublicKeyPem should also identify the variant from the OID + const info = await parsePublicKeyPem(pem); + expect(info.algorithm).to.equal(`mlkem:${level}`); + }); + + it('SPKI DER round-trip preserves raw key bytes', () => { + const expectedRawSize = { 768: 1184, 1024: 1568 }[level]; + const raw = new Uint8Array(expectedRawSize).map((_, i) => i & 0xff); + const der = encodeMlKemSpkiDer(raw, level); + const decoded = decodeMlKemSpkiDer(der); + expect(decoded.level).to.equal(level); + expect(decoded.rawKey).to.deep.equal(raw); + // Re-encoding the decoded form must yield the same DER bytes + expect(encodeMlKemSpkiDer(decoded.rawKey, decoded.level)).to.deep.equal(der); + }); + + it('rejects a BIT STRING that over-claims past the DER buffer', () => { + const expectedRawSize = { 768: 1184, 1024: 1568 }[level]; + const raw = new Uint8Array(expectedRawSize).map((_, i) => i & 0xff); + const der = encodeMlKemSpkiDer(raw, level); + // BIT STRING content = rawKey + 1 unused-bits byte, encoded with a 2-byte + // long-form length header immediately before it. Inflate that header in + // place: the outer SEQUENCE length still matches der.length, but the BIT + // STRING now claims more bytes than remain. Without the bounds check this + // slid through slice() as a silently-truncated rawKey. + const contentStart = der.length - (expectedRawSize + 1); + der[contentStart - 2] = 0xff; + der[contentStart - 1] = 0xff; + expect(() => decodeMlKemSpkiDer(der)).to.throw(/BIT STRING length does not match/); + }); + + it('publicKeyAlgorithmToJwa returns the draft-JOSE PQ KEM name', () => { + const expected = { + 768: 'ML-KEM-768+A192KW', + 1024: 'ML-KEM-1024+A256KW', + }[level]; + expect(publicKeyAlgorithmToJwa(`mlkem:${level}`)).to.equal(expected); + }); + }); + } + + describe('hkdfDerive', () => { + it('produces deterministic 32-byte output for fixed inputs', async () => { + const { sharedSecret } = await mlKemEncapsulate((await generateMlKemKeyPair(768)).publicKey); + const salt = new Uint8Array(32); + const derived1 = await hkdfDerive(sharedSecret, { hash: 'SHA-256', salt }); + const derived2 = await hkdfDerive(sharedSecret, { hash: 'SHA-256', salt }); + const key1Bytes = unwrapSymmetricKey(derived1); + const key2Bytes = unwrapSymmetricKey(derived2); + expect(key1Bytes).to.deep.equal(key2Bytes); + expect(key1Bytes.length).to.equal(32); + }); + + it('produces different keys for different salts', async () => { + const { sharedSecret } = await mlKemEncapsulate((await generateMlKemKeyPair(768)).publicKey); + const salt1 = new Uint8Array(32).fill(1); + const salt2 = new Uint8Array(32).fill(2); + const key1 = unwrapSymmetricKey( + await hkdfDerive(sharedSecret, { hash: 'SHA-256', salt: salt1 }) + ); + const key2 = unwrapSymmetricKey( + await hkdfDerive(sharedSecret, { hash: 'SHA-256', salt: salt2 }) + ); + expect(key1).to.not.deep.equal(key2); + }); + }); + + describe('isPublicKeyAlgorithm', () => { + it('returns true for all 7 valid tokens', () => { + const valid = [ + 'ec:secp256r1', + 'ec:secp384r1', + 'ec:secp521r1', + 'rsa:2048', + 'rsa:4096', + 'mlkem:768', + 'mlkem:1024', + ]; + for (const alg of valid) { + expect(isPublicKeyAlgorithm(alg), alg).to.be.true; + } + }); + + it('returns false for invalid tokens', () => { + const invalid = ['ec:foo', 'rsa:1024', 'mlkem:256', 'mlkem:512', 'aes:256', '', 'rsa']; + for (const alg of invalid) { + expect(isPublicKeyAlgorithm(alg), alg).to.be.false; + } + }); + }); +}); diff --git a/lib/tests/mocha/unit/mlkem-key-access.spec.ts b/lib/tests/mocha/unit/mlkem-key-access.spec.ts new file mode 100644 index 000000000..63091a3d1 --- /dev/null +++ b/lib/tests/mocha/unit/mlkem-key-access.spec.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai'; + +import { MlKemWrapped } from '../../../tdf3/src/models/key-access.js'; +import { Policy } from '../../../tdf3/src/models/policy.js'; +import { base64 } from '../../../src/encodings/index.js'; +import { ConfigurationError } from '../../../src/errors.js'; +import type { CryptoService, PublicKey } from '../../../tdf3/src/crypto/declarations.js'; +import { Binary } from '../../../tdf3/src/binary.js'; +import { importSymmetricKey } from '../../../tdf3/src/crypto/index.js'; +import { decodeKemEnvelopeDer } from '../../../tdf3/src/crypto/core/mlkem-asn1.js'; + +// ML-KEM-768 ciphertext length, per FIPS 203. +const MLKEM768_CT_LEN = 1088; + +// Minimal CryptoService stub covering only what MlKemWrapped.write() touches: +// ML-KEM encapsulation, AES-GCM wrap of the DEK, and the policy-binding HMAC. +// (No HKDF/digest: ML-KEM uses the raw shared secret directly as the AES key.) +const mockCryptoService: CryptoService = { + async importPublicKey(): Promise { + return { _brand: 'PublicKey', algorithm: 'mlkem:768', mlKemLevel: 768 }; + }, + async mlKemEncapsulate() { + return { + ciphertext: new Uint8Array(MLKEM768_CT_LEN), + sharedSecret: await importSymmetricKey(new Uint8Array(32)), + }; + }, + async randomBytes(length: number): Promise { + return new Uint8Array(length); + }, + async encrypt() { + return { + payload: Binary.fromArrayBuffer(new Uint8Array(16).buffer), + authTag: Binary.fromArrayBuffer(new Uint8Array(16).buffer), + }; + }, + async hmac(): Promise { + return new Uint8Array(32); + }, +} as unknown as CryptoService; + +describe('MlKemWrapped', () => { + const url = 'https://example.com'; + const kid = 'test-kid'; + const publicKey = 'test-public-key'; + const metadata = { key: 'value' }; + const sid = 'test-sid'; + const alg = 'mlkem:768' as const; + const policy: Policy = { uuid: 'test-policy' }; + const encryptedMetadataStr = 'encrypted-metadata'; + + it("initializes with type 'mlkem-wrapped' and level from alg", () => { + const mlKemWrapped = new MlKemWrapped( + url, + kid, + publicKey, + metadata, + mockCryptoService, + sid, + alg + ); + expect(mlKemWrapped.type).to.equal('mlkem-wrapped'); + expect(mlKemWrapped.level).to.equal(768); + }); + + it("writes a KeyAccessObject with type 'mlkem-wrapped'", async () => { + const mlKemWrapped = new MlKemWrapped( + url, + kid, + publicKey, + metadata, + mockCryptoService, + sid, + alg + ); + + const dek = await importSymmetricKey(new Uint8Array([1, 2, 3, 4, 5])); + const kao = await mlKemWrapped.write(policy, dek, encryptedMetadataStr); + + expect(kao).to.have.property('type', 'mlkem-wrapped'); + expect(kao).to.have.property('url', url); + expect(kao).to.have.property('protocol', 'kas'); + expect(kao).to.have.property('wrappedKey'); + expect(kao).to.have.property('encryptedMetadata', base64.encode(encryptedMetadataStr)); + expect(kao).to.have.property('kid', kid); + expect(kao).to.have.property('sid', sid); + expect(kao.policyBinding).to.have.property('alg', 'HS256'); + + // wrappedKey is a DER kemEnvelope { [0] kemCiphertext, [1] encryptedDek } + // where encryptedDek = nonce(12) || aes-256-gcm ct || tag(16). + const { kemCiphertext, encryptedDek } = decodeKemEnvelopeDer( + new Uint8Array(base64.decodeArrayBuffer(kao.wrappedKey!)) + ); + expect(kemCiphertext.length).to.equal(MLKEM768_CT_LEN); + expect(encryptedDek.length).to.equal(12 + 16 + 16); + }); + + it('requires a non-empty kid', () => { + expect( + () => new MlKemWrapped(url, '', publicKey, metadata, mockCryptoService, sid, alg) + ).to.throw(ConfigurationError); + }); +}); diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index 28919cc2d..d6aa2f616 100644 --- a/lib/tests/mocha/unit/tdf.spec.ts +++ b/lib/tests/mocha/unit/tdf.spec.ts @@ -8,6 +8,7 @@ import { ConfigurationError, InvalidFileError, UnsafeUrlError } from '../../../s import { getMocks } from '../../mocks/index.js'; import * as DefaultCryptoService from '../../../tdf3/src/crypto/index.js'; import type { CryptoService } from '../../../tdf3/src/crypto/declarations.js'; +import { isMlKemKeyAlgorithm } from '../../../tdf3/src/crypto/declarations.js'; const sampleCert = ` -----BEGIN CERTIFICATE----- @@ -47,6 +48,11 @@ HJg= const { kasECCert } = getMocks(); +// `process` is undefined in the browser test runner (karma), so guard access. +// BASE_KEY_ALG is a local-dev toggle used to point the mock server at an +// ML-KEM base key; when unset (as in CI) these tests expect the default EC key. +const baseKeyAlg = typeof process !== 'undefined' ? process.env.BASE_KEY_ALG || '' : ''; + describe('TDF', () => { const cryptoService: CryptoService = DefaultCryptoService; @@ -94,8 +100,13 @@ describe('fetchKasPublicKey', async () => { it('localhost kas is valid', async () => { const pk2 = await TDF.fetchKasPublicKey('http://localhost:3000'); - expect(pk2.publicKey).to.include('BEGIN CERTIFICATE'); - expect(pk2.kid).to.equal('e1'); + if (isMlKemKeyAlgorithm(baseKeyAlg)) { + expect(pk2.publicKey).to.include('BEGIN PUBLIC KEY'); + expect(pk2.kid).to.match(/^mlkem(768|1024)$/); + } else { + expect(pk2.publicKey).to.include('BEGIN CERTIFICATE'); + expect(pk2.kid).to.equal('e1'); + } }); it('invalid algorithms', async () => { @@ -110,8 +121,13 @@ describe('fetchKasPublicKey', async () => { it('localhost BaseKey', async () => { const pk2 = await TDF.fetchKasPublicKey('http://localhost:3000'); - expect(pk2.publicKey).to.include('BEGIN CERTIFICATE'); - expect(pk2.kid).to.equal('e1'); + if (isMlKemKeyAlgorithm(baseKeyAlg)) { + expect(pk2.publicKey).to.include('BEGIN PUBLIC KEY'); + expect(pk2.kid).to.match(/^mlkem(768|1024)$/); + } else { + expect(pk2.publicKey).to.include('BEGIN CERTIFICATE'); + expect(pk2.kid).to.equal('e1'); + } }); }); @@ -260,8 +276,8 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); expect(result).to.deep.equal({ - split1: [keyAccess[0]], - split2: [keyAccess[1]], + split1: { 'https://kas1': keyAccess[0] }, + split2: { 'https://kas2': keyAccess[1] }, }); }); @@ -275,8 +291,8 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); expect(result).to.deep.equal({ - split1: [keyAccess[0]], - split2: [keyAccess[1]], + split1: { 'https://kas1': keyAccess[0] }, + split2: { 'https://kas2': keyAccess[1] }, }); }); @@ -293,33 +309,17 @@ describe('splitLookupTableFactory', () => { ); }); - it('should keep duplicate URLs in the same splitId as alternatives (DSPX-3379)', () => { + it('should throw for duplicate URLs in the same splitId', () => { const keyAccess: KeyAccessObject[] = [ { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, - { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // same KAS + split - ]; - const allowedKases = new OriginAllowList(['https://kas1']); - - const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); - - // Both copies are retained as disjunction alternatives; unwrap tries each. - expect(result).to.deep.equal({ - split1: [keyAccess[0], keyAccess[1]], - }); - }); - - it('should keep same-KAS different-kid entries in the same splitId (DSPX-3379)', () => { - const keyAccess: KeyAccessObject[] = [ - { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k1' }, - { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k2' }, + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // duplicate URL in same splitId ]; const allowedKases = new OriginAllowList(['https://kas1']); - const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); - - expect(result).to.deep.equal({ - split1: [keyAccess[0], keyAccess[1]], - }); + expect(() => TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.throw( + InvalidFileError, + 'Unable to decrypt: Multiple keys detected for Key Access Server [https://kas1]. Please contact your administrator.' + ); }); it('should handle empty keyAccess array', () => { @@ -352,7 +352,7 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, new OriginAllowList(allowedKases)); expect(result).to.deep.equal({ - '': [keyAccess[0]], + '': { 'https://kas1': keyAccess[0] }, }); }); }); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 6f389b201..83e78a343 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,17 +1,45 @@ import * as jose from 'jose'; import { createServer, IncomingMessage, RequestListener } from 'node:http'; +import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'; import { base64 } from '../src/encodings/index.js'; import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; import { getMocks } from './mocks/index.js'; import { keyAgreement, pemPublicToCrypto } from '../src/crypto/index.js'; import { generateRandomNumber } from '../src/crypto/generateRandomNumber.js'; -import { removePemFormatting } from '../tdf3/src/crypto/crypto-utils.js'; +import { formatAsPem, removePemFormatting } from '../tdf3/src/crypto/crypto-utils.js'; import { Binary } from '../tdf3/index.js'; import { valueFor } from './web/policy/mock-attrs.js'; import { AttributeAndValue } from '../src/policy/attributes.js'; import { getZtdfSalt } from '../tdf3/src/crypto/salt.js'; import { DefaultCryptoService } from '../tdf3/src/crypto/index.js'; +import { isMlKemKeyAlgorithm, mlKemAlgorithmToLevel } from '../tdf3/src/crypto/declarations.js'; +import { + decodeKemEnvelopeDer, + decodeMlKemSpkiDer, + encodeKemEnvelopeDer, + encodeMlKemSpkiDer, + isMlKemSpkiDer, +} from '../tdf3/src/crypto/core/mlkem-asn1.js'; + +// ML-KEM server-side key pairs, generated once at startup. +const KAS_ML_KEM_KEYS = { + 768: ml_kem768.keygen(), + 1024: ml_kem1024.keygen(), +} as const; + +const MLKEM_APIS = { 768: ml_kem768, 1024: ml_kem1024 } as const; + +function mlKemPublicKeyPem(level: 768 | 1024): string { + const der = encodeMlKemSpkiDer(KAS_ML_KEM_KEYS[level].publicKey, level); + // formatAsPem expects an ArrayBuffer; copy out of the underlying buffer. + const ab = der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength); + return formatAsPem(ab, 'PUBLIC KEY'); +} + +// Allow tests to flip the WellKnown base key by setting BASE_KEY_ALG=mlkem:768 +// (or any other supported algorithm) before importing this module. Default stays EC. +const BASE_KEY_ALG = process.env.BASE_KEY_ALG || 'ec:secp256r1'; import { create, toJsonString, fromJson } from '@bufbuild/protobuf'; import { ValueSchema } from '@bufbuild/protobuf/wkt'; @@ -95,12 +123,20 @@ const kas: RequestListener = async (req, res) => { const params = JSON.parse(bodyText); const algorithm = params.algorithm || 'rsa:2048'; - if (!['ec:secp256r1', 'rsa:2048'].includes(algorithm)) { + const validAlgorithms = ['ec:secp256r1', 'rsa:2048', 'mlkem:768', 'mlkem:1024']; + if (!validAlgorithms.includes(algorithm)) { console.log(`[DEBUG] invalid algorithm [${algorithm}]`); res.writeHead(400); res.end(`{"error": "Invalid algorithm [${algorithm}]"}`); return; } + if (isMlKemKeyAlgorithm(algorithm)) { + const level = mlKemAlgorithmToLevel(algorithm); + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 200; + res.end(JSON.stringify({ kid: `mlkem${level}`, publicKey: mlKemPublicKeyPem(level) })); + return; + } const fmt = params.fmt || 'pkcs8'; if (!['jwks', 'pkcs8'].includes(fmt)) { console.log(`[DEBUG] invalid fmt [${fmt}]`); @@ -209,12 +245,31 @@ const kas: RequestListener = async (req, res) => { const rewrap = fromJson(UnsignedRewrapRequestSchema, JSON.parse(requestBody as string)); console.log('[INFO]: rewrap request body: ', rewrap); - const clientPublicKey = await pemPublicToCrypto(rewrap.clientPublicKey); - if (!clientPublicKey || clientPublicKey.type !== 'public') { - res.writeHead(400); - res.end('{"error": "Invalid client public key"}'); - return; + + // All clientPublicKey strings now arrive as PEM SPKI. Decode once and decide + // whether it's ML-KEM (by OID) or a WebCrypto-friendly RSA/EC key. + const clientDer = new Uint8Array( + base64.decodeArrayBuffer(removePemFormatting(rewrap.clientPublicKey)) + ); + let clientKeyRaw: Uint8Array | undefined; + let clientMlKemLevel: 768 | 1024 | undefined; + if (isMlKemSpkiDer(clientDer)) { + const decoded = decodeMlKemSpkiDer(clientDer); + clientMlKemLevel = decoded.level; + clientKeyRaw = decoded.rawKey; } + const isMLKEMClient = clientMlKemLevel !== undefined; + + let clientPublicKey: CryptoKey | undefined; + if (!isMLKEMClient) { + clientPublicKey = await pemPublicToCrypto(rewrap.clientPublicKey); + if (!clientPublicKey || clientPublicKey.type !== 'public') { + res.writeHead(400); + res.end('{"error": "Invalid client public key"}'); + return; + } + } + const keyAccessObject = rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject; const kaoheader = keyAccessObject?.header; const isZTDF = !kaoheader || kaoheader.length === 0; @@ -225,10 +280,36 @@ const kas: RequestListener = async (req, res) => { res.end('{"error": "Invalid wrapped key"}'); return; } - const isECWrapped = keyAccessObject?.kid == 'e1'; + const kid = keyAccessObject?.kid || ''; + const isECWrapped = kid == 'e1'; + const isMlKemWrapped = kid === 'mlkem768' || kid === 'mlkem1024'; // Decrypt the wrapped key from TDF3 let dek: Binary; - if (isECWrapped) { + if (isMlKemWrapped) { + const kasLevel = parseInt(kid.replace('mlkem', ''), 10) as 768 | 1024; + // ML-KEM "direct key wrap" (mirrors opentdf/platform): + // wrappedKey = DER( kemEnvelope { [0] kemCiphertext, [1] encryptedDek } ) + // encryptedDek = nonce(12) || aes_ct || tag(16) + // AES-256 key = raw ML-KEM shared secret (no HKDF) + const { kemCiphertext, encryptedDek } = decodeKemEnvelopeDer(wk); + const iv = encryptedDek.slice(0, 12); + const wrappedDek = encryptedDek.slice(12); + + const sharedSecret = MLKEM_APIS[kasLevel].decapsulate( + kemCiphertext, + KAS_ML_KEM_KEYS[kasLevel].secretKey + ); + + const aesKey = await crypto.subtle.importKey( + 'raw', + sharedSecret, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + const dekAb = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, wrappedDek); + dek = Binary.fromArrayBuffer(dekAb); + } else if (isECWrapped) { if (!keyAccessObject?.ephemeralPublicKey) { res.writeHead(400); res.end('{"error": "Nil ephemeral public key"}'); @@ -258,7 +339,53 @@ const kas: RequestListener = async (req, res) => { } else { dek = await decryptWithPrivateKey(Binary.fromArrayBuffer(wk), Mocks.kasPrivateKey); } - if (clientPublicKey.algorithm.name == 'RSA-OAEP') { + if (isMLKEMClient && clientMlKemLevel !== undefined && clientKeyRaw !== undefined) { + const { cipherText, sharedSecret } = + MLKEM_APIS[clientMlKemLevel].encapsulate(clientKeyRaw); + // Direct key wrap: raw shared secret is the AES-256 key (no HKDF). + const newAesKey = await crypto.subtle.importKey( + 'raw', + sharedSecret, + { name: 'AES-GCM' }, + false, + ['encrypt'] + ); + const iv = generateRandomNumber(12); + const aesCt = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + newAesKey, + dek.asArrayBuffer() + ); + // entityWrappedKey = DER( kemEnvelope { [0] kemCiphertext, [1] encryptedDek } ) + // where encryptedDek = nonce(12) || aes_ct || tag(16) + const encryptedDek = concat([iv, new Uint8Array(aesCt)]); + const entityWrappedKey = encodeKemEnvelopeDer(cipherText, encryptedDek); + const reply = create(RewrapResponseSchema, { + responses: [ + create(PolicyRewrapResultSchema, { + results: [ + create(KeyAccessRewrapResultSchema, { + metadata: { + hello: create(ValueSchema, { + kind: { case: 'stringValue', value: 'world' }, + }), + }, + result: { + case: 'kasWrappedKey', + value: entityWrappedKey, + }, + keyAccessObjectId: + rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObjectId || '', + }), + ], + }), + ], + }); + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(toJsonString(RewrapResponseSchema, reply)); + return; + } else if (clientPublicKey!.algorithm.name == 'RSA-OAEP') { // Import the client public key as opaque PublicKey for encryptWithPublicKey const clientPubKeyOpaque = await DefaultCryptoService.importPublicKey( rewrap.clientPublicKey, @@ -299,7 +426,7 @@ const kas: RequestListener = async (req, res) => { false, ['deriveBits', 'deriveKey'] ); - const kek = await keyAgreement(sessionKeyPair.privateKey, clientPublicKey, { + const kek = await keyAgreement(sessionKeyPair.privateKey, clientPublicKey!, { hkdfSalt: await getZtdfSalt(DefaultCryptoService), hkdfHash: 'SHA-256', }); @@ -442,17 +569,24 @@ const kas: RequestListener = async (req, res) => { ) { res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); + let publicKey: { algorithm: string; kid: string; pem: string }; + if (isMlKemKeyAlgorithm(BASE_KEY_ALG)) { + const level = mlKemAlgorithmToLevel(BASE_KEY_ALG); + publicKey = { + algorithm: BASE_KEY_ALG, + kid: `mlkem${level}`, + pem: mlKemPublicKeyPem(level), + }; + } else { + publicKey = { algorithm: 'ec:secp256r1', kid: 'e1', pem: Mocks.kasECCert }; + } res.end( JSON.stringify({ configuration: { base_key: { kas_id: '34f2acdc-3d9c-4e92-80b6-90fe4dc9afcb', kas_uri: 'http://localhost:3000', - public_key: { - algorithm: 'ec:secp256r1', - kid: 'e1', - pem: Mocks.kasECCert, - }, + public_key: publicKey, }, }, }) diff --git a/spec/DSPX-3229.md b/spec/DSPX-3229.md new file mode 100644 index 000000000..a5e7140f3 --- /dev/null +++ b/spec/DSPX-3229.md @@ -0,0 +1,297 @@ +--- +ticket: DSPX-3229 +title: ml-kem kaos +status: draft +authors: [dmihalcik@virtru.com, sujankota@virtru.com] +branches: [opentdf/web-sdk:DSPX-3229-ml-kem-kaos] +prs: [] +created: 2026-05-08 +updated: 2026-05-13 +--- + +# ml-kem kaos + +## Summary +Add NIST FIPS 203 ML-KEM as a third key-wrap family on TDF Key Access +Objects, alongside RSA-OAEP and ECDH+HKDF. Introduces algorithm strings +`mlkem:768`, `mlkem:1024`, with `mlkem:768` as the new SDK +default once the KAS rollout lands. ML-KEM KAOs use a distinct +`type: 'mlkem-wrapped'` (matching opentdf/platform's Go KAS and java-sdk, +which discriminate ML-KEM rewrap on this `type`); the algorithm string +carries the variant. The wrappedKey blob is a DER `kemEnvelope` carrying +the ML-KEM ciphertext alongside the AES-256-GCM-wrapped DEK, using the +platform's "direct key wrap" (the raw ML-KEM shared secret is the AES key, +no HKDF). + +## Problem / Motivation +TDF payloads are designed to be persisted and exchanged for years. +Wrapping the per-object DEK with classical RSA-OAEP or ECDH leaves the +payload exposed to a future quantum adversary capturing ciphertext today +and decrypting later ("harvest now, decrypt later"). Customers in +regulated environments are also beginning to require FIPS 203 readiness +on key-encapsulation surfaces. The SDK needs to be able to (a) request a +post-quantum public key from a KAS, (b) wrap a DEK to it, and +(c) participate in a rewrap exchange whose ephemeral client key is +ML-KEM, without breaking the existing classical paths or the TDF wire +format. + +## Proposed Solution +- Add `mlkem:768 | mlkem:1024` to the + `KasPublicKeyAlgorithm` union (`lib/src/access.ts:90`) and the + internal `KeyAlgorithm` union + (`lib/tdf3/src/crypto/declarations.ts:27`). Update + `PublicKeyInfo.algorithm` (`declarations.ts:159`) likewise. +- Use a distinct KAO `type: 'mlkem-wrapped'` (opentdf/platform's Go KAS + and java-sdk key ML-KEM rewrap off this `type`; the SDK forwards the KAO + `type` to the KAS verbatim as the rewrap `keyType`). The `wrappedKey` + field becomes: + + ```asn1 + base64( DER( kemEnvelope { + [0] IMPLICIT OCTET STRING kemCiphertext, + [1] IMPLICIT OCTET STRING encryptedDek } ) ) + + where encryptedDek = nonce(12) || aes-256-gcm ct || tag(16) + ``` + + where `kemCiphertext` is 1088 / 1568 bytes for ML-KEM-768 / 1024 + respectively. This matches the platform's canonical "direct key wrap" + (`opentdf/platform lib/ocrypto`): the raw 32-byte shared secret returned + by `ML-KEM.Encaps` / `ML-KEM.Decaps` is used **directly as the + AES-256-GCM key — no HKDF, no `getZtdfSalt`**. The envelope is encoded / + decoded by `encodeKemEnvelopeDer` / `decodeKemEnvelopeDer` in + `lib/tdf3/src/crypto/core/mlkem-asn1.ts`. +- Add a sibling `MlKemWrapped` class next to `ECWrapped` and `Wrapped` + in `lib/tdf3/src/models/key-access.ts` whose `.write()` performs + encapsulation, AES-256-GCM wrap of the DEK, and DER-envelope encoding, + and emits a KAO with `type: 'mlkem-wrapped'` and a **required** non-empty + `kid` whose KAS-side algorithm metadata is `mlkem:`. +- Extend the algorithm switch in `lib/tdf3/src/client/index.ts` to map + `mlkem:*` to `type = 'mlkem-wrapped'`, and add a `case 'mlkem-wrapped'` + in `buildKeyAccess` (`lib/tdf3/src/tdf.ts`) that routes to + `MlKemWrapped`. +- Extend rewrap-request building in `lib/tdf3/src/tdf.ts:773-843` to + generate an ML-KEM client keypair (when configured) and send the raw + encapsulation-key bytes (base64) as `clientPublicKey`. Response + parsing dispatches on the locally-known client algorithm. +- New CryptoService methods (`lib/tdf3/src/crypto/declarations.ts:164`): + - `generateMlKemKeyPair(level: 768 | 1024): Promise` + - `mlKemEncapsulate(pk: PublicKey): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }>` + - `mlKemDecapsulate(sk: PrivateKey, ct: Uint8Array): Promise` + - `importPublicKey` / `exportPublicKeyPem` handle ML-KEM keys as + standard PEM-wrapped SPKI using the NIST OIDs + `2.16.840.1.101.3.4.4.{2,3}` (`id-alg-ml-kem-{768,1024}`). + The PEM banner is `-----BEGIN PUBLIC KEY-----`, matching RSA/EC; + `parsePublicKeyPem` detects the variant by OID alongside the + existing RSA/EC checks. ML-KEM SPKI PEMs produced by + `openssl genpkey -algorithm ML-KEM-768 | openssl pkey -pubout` + must round-trip through the SDK without modification. +- `publicKeyAlgorithmToJwa` returns the draft-JOSE PQ KEM algorithm + strings for ML-KEM (per + [draft-ietf-jose-pqc-kem-05 §9](https://www.ietf.org/archive/id/draft-ietf-jose-pqc-kem-05.html#section-9)): + `mlkem:768` → `ML-KEM-768+A192KW`, + `mlkem:1024` → `ML-KEM-1024+A256KW`. These names are exposed at the + SDK boundary (algorithm metadata, future JWE labels); they are + informational and do not bind the TDF wire format. +- AES wrap-key size: the TDF `wrappedKey` blob continues to use + **AES-256-GCM** for all three ML-KEM levels (consistent with + `ECWrapped`'s curve-independent AES-256-GCM choice). The JWA strings + `AnnnKW` describe RFC 3394 AES Key Wrap, not AES-GCM — they are + algorithm-pair identifiers, not a binding on the inner primitive. + Rationale: (a) WebCrypto AES-GCM only supports 128/256-bit keys in + practice, so a strict per-level pairing would require a custom KW + implementation; (b) GCM is authenticated and KW is not, so + downgrading would lose integrity protection on the wrapped DEK; + (c) over-wrapping a 128-bit-strength KEM with 256-bit AES is one + extra cipher round-trip — trivial cost. +- `fetchKasPubKey` is algorithm-agnostic: the WellKnown base-key probe + runs for every algorithm including `mlkem:*`. This lets a KAS + advertise an ML-KEM base key without the SDK skipping over it. +- Implementation library: `@noble/post-quantum` (audited TS, + browser+Node, no native deps, FIPS 203 ML-KEM-768/1024). New + dependency added to `lib/package.json`. +- Default flip: `lib/src/access/access-fetch.ts` and `access-rpc.ts` + default of `'rsa:2048'` switches to `'mlkem:768'` only when the KAS + rollout (tracked separately) advertises it. Until then, the default + stays as today; SDK consumers can opt in explicitly via + `--encapsulation-algorithm mlkem:768`. + +## Inputs / Outputs / Contracts + +### New algorithm tokens +Valid wherever `KasPublicKeyAlgorithm` / `KeyAlgorithm` is accepted: +`'mlkem:768' | 'mlkem:1024'`. + +### Manifest contract +KAO schema is unchanged. For ML-KEM-wrapped KAOs: +- `type`: `'mlkem-wrapped'` +- `kid`: **required** — identifies the KAS public key; KAS-side metadata + records algorithm = `mlkem:`. Unlike RSA/EC there is no way to recover + the key without it, so a missing/blank `kid` is rejected at build time. +- `wrappedKey`: + `base64(DER(kemEnvelope{ [0] kemCiphertext, [1] encryptedDek }))` where + `encryptedDek = nonce(12) || aes_gcm_ct || tag(16)`, as above +- `ephemeralPublicKey`: omitted (not used for ML-KEM) +- All other fields (`policyBinding`, `encryptedMetadata`, `protocol`, + `schemaVersion`) unchanged. + +### CryptoService additions +See "Proposed Solution" above. ML-KEM keys are opaque `PublicKey` / +`PrivateKey` instances carrying `algorithm: 'mlkem:'` and a new +optional `mlKemLevel?: 768 | 1024` field on the discriminated +union; they are not WebCrypto `CryptoKey`s. + +### CLI surface (`cli/src/cli.ts:460-478`) +- `--encapsulation-algorithm mlkem:768|mlkem:1024` (existing + flag, expanded value set) +- `--rewrap-encapsulation-algorithm mlkem:768|mlkem:1024` + (existing flag, expanded value set) +- Default value for both stays `rsa:2048` until KAS-side gate flips. + +### KAS public-key fetch +Existing `fetchKasPubKey(endpoint, algorithm)` accepts the new tokens +on the `?algorithm=` query parameter. Response shape unchanged +(`{ publicKey, kid, algorithm }`); `publicKey` for `mlkem:*` is a +PEM-wrapped SPKI envelope (`-----BEGIN PUBLIC KEY-----`) carrying the +NIST OID `id-alg-ml-kem-{768,1024}` and the raw encapsulation key +bytes in a BIT STRING. The `algorithm` field corroborates parsing but +is redundant once the OID has been read. + +The WellKnown base-key probe (`fetchKasBasePubKey`) runs unconditionally +for ML-KEM as well as RSA/EC, so a KAS may publish an ML-KEM key as +its base key. + +### Rewrap request wire (informative; authoritative spec lives in opentdf/platform) +- Client generates ML-KEM keypair locally. +- `UnsignedRewrapRequest.clientPublicKey` carries the client's + encapsulation key as PEM-wrapped SPKI (same envelope as + `KasPublicKeyInfo.publicKey`), so the KAS uses the same OID-based + detection as the SDK does. +- The platform side adds the algorithm discriminator to the rewrap + request body — captured in the platform spec, not here. + +## Edge Cases & Constraints +- WebCrypto does not yet expose ML-KEM. The implementation lives behind + `CryptoService`, so HSM-backed integrations can override it without + touching call sites. +- Manifest size grows: ML-KEM-768 adds ~1088 bytes per wrap on top of + the AES-GCM blob; multi-KAS split-key TDFs multiply this. No hard cap + is hit in the current TDF3 ZIP layout, but tests should assert on + realistic sizes. +- `isPublicKeyAlgorithm` at `lib/src/access.ts:97-99` only matches two + of the five existing tokens today — fix it in the same change so it + enumerates all classical members plus the two ML-KEM members. +- `keyAlgorithmToPublicKeyAlgorithm` (`lib/src/access.ts:101`) operates + on WebCrypto `CryptoKey`. ML-KEM keys live as opaque + `PublicKey`/`PrivateKey` instances with their own algorithm metadata + and never pass through this helper — they don't need to, because the + SDK now relies on `parsePublicKeyPem` (OID-based) to identify + algorithms from PEM envelopes. +- `parsePublicKeyPem` recognises ML-KEM SPKI by checking for the two + NIST OIDs in the hex-encoded SPKI bytes alongside the existing + RSA/EC OID checks. Detection is structural (not heuristic on key + size), so it's robust against future ML-KEM variants sharing key + sizes with classical algorithms. +- The ML-KEM unwrap path validates the `entityWrappedKey` / + `kemEnvelope` field lengths (KEM ciphertext against `MLKEM_CT_SIZES`, + and the `encryptedDek` minimum of nonce+tag) before slicing, so + truncated or malformed input fails closed with a clear `DecryptError` + rather than a low-level slice error. +- AES wrap key remains 256-bit for all ML-KEM levels — see Proposed + Solution above for rationale. +- ML-KEM is encryption-only. Do NOT touch signing paths (DPoP, request + signature, `policyBinding`). Those remain RS256/ES256. +- FIPS 203 mandates implicit rejection on decapsulation failure. Rely + on `@noble/post-quantum`'s `decapsulate` — do not reimplement the + branching. +- Backward compatibility: readers that don't know `mlkem:*` must fail + closed with a clear, non-fallback error message. +- Default flip is gated on KAS readiness; SDK ships with `mlkem:768` + available but not yet default until that gate flips. The default + change itself is a one-line follow-up PR once KAS is rolled out. +- `@noble/post-quantum` license check (MIT) must pass + `make license-check`. + +## Out of Scope +- Hybrid KEM suites combining ML-KEM with X25519 / EC (e.g. + `mlkem-hybrid:x25519-mlkem768`). Reserved for a follow-up ticket; the + algorithm-string namespace leaves room. +- KAS-side decapsulation, ML-KEM private-key generation/storage, and + the rewrap wire-protocol algorithm field — tracked in + opentdf/platform. +- ML-DSA / Dilithium signatures and any post-quantum signature + surface — separate PQ track. +- nanoTDF wire-format changes. nanoTDF's fixed-size 33-byte ephemeral + pubkey layout cannot accommodate ML-KEM ciphertexts; nanoTDF stays + EC-only for now. +- Migration tooling for re-wrapping legacy classical TDFs to ML-KEM in + bulk. + +## Acceptance Criteria +- [ ] `mlkem:768 | mlkem:1024` are accepted in every type + union and runtime guard where `rsa:*`/`ec:*` are accepted today + (audit `lib/src`, `lib/tdf3/src`, `cli/src`). +- [ ] `isPublicKeyAlgorithm` (`lib/src/access.ts:97`) returns true for + exactly the seven valid tokens and false for everything else; covered + by unit test. +- [ ] `lib/tests/mocha/encrypt-decrypt.spec.ts` exercises both + ML-KEM sizes (`mlkem:768`, `mlkem:1024`) in the same + encrypt/decrypt patterns used for the existing classical algorithms. +- [ ] `web-app/tests/server.ts` (the Playwright test KAS server) + implements ML-KEM server-side key generation, encapsulation/rewrap + response, and advertises `mlkem:*` keys via its public-key endpoint. +- [ ] `web-app/tests/roundtrip.spec.ts` (or equivalent Playwright spec) + includes end-to-end roundtrip test cases for `mlkem:768` (and at + least one other ML-KEM size) covering encrypt-in-browser → + rewrap-via-test-server → decrypt-in-browser, and asserts that the + produced manifest's KAO `type` is `mlkem-wrapped` and its `kid` matches + the requested ML-KEM variant (via the new KAO metadata panel in the + sample app, see below). +- [ ] `web-app/src` (the sample app) is updated to include: + - An encapsulation-algorithm dropdown so the app can be used to + manually verify ML-KEM encrypt/decrypt flows. + - A "Manifest inspector" panel (`#kaoMetadata`) that, on decrypt, + parses the input TDF's manifest and surfaces per-KAO `kid`, + `type`, `alg`, and `url`. Element IDs are stable + (`#kao-kid-0`, `#kao-alg-0`, etc.) so Playwright can assert on + them deterministically. +- [ ] `publicKeyAlgorithmToJwa('mlkem:768' | 'mlkem:1024')` + returns `'ML-KEM-768+A192KW' | 'ML-KEM-1024+A256KW'` + respectively (no throws), covered by unit test. +- [ ] ML-KEM public keys round-trip through PEM SPKI: + `exportPublicKeyPem(importPublicKey(openssl_pem))` produces a + byte-identical PEM, and `parsePublicKeyPem(pem)` reports the + matching `mlkem:N` algorithm. +- [ ] `fetchKasPubKey` accepts an `mlkem:*` WellKnown base key — + covered by a `lib/tests/server.ts` configuration scenario in which + the mock WellKnown response advertises an ML-KEM key and the SDK + successfully encrypts/decrypts a payload using it as the default. +- [ ] Encrypting a payload with `--encapsulation-algorithm mlkem:768` + against a KAS configured for ML-KEM, then decrypting via rewrap with + the same algorithm, recovers the exact original payload (round-trip + integration test). +- [ ] ML-KEM KAOs are written with `type: 'mlkem-wrapped'` (unit-tested + in `lib/tests/mocha/unit/mlkem-key-access.spec.ts`), while RSA stays + `'wrapped'` and EC stays `'ec-wrapped'`. +- [ ] ML-KEM requires a `kid`: constructing `MlKemWrapped` or calling + `buildKeyAccess` for `mlkem:*` without a non-empty `kid` throws a + `ConfigurationError`. +- [ ] `wrappedKey` blob for ML-KEM decodes via `decodeKemEnvelopeDer` as + `DER(kemEnvelope{ [0] kemCiphertext(N), [1] encryptedDek })` where + `encryptedDek = nonce(12) || aes-256-gcm ct || tag(16)`, and the format + is fixed by a snapshot test. +- [ ] A JS-produced `mlkem-wrapped` TDF is accepted (unwrapped) by the + opentdf/platform Go KAS and the java-sdk — exercised by the + cross-SDK roundtrip CI. +- [ ] Reading a TDF previously wrapped with `rsa:2048` or + `ec:secp256r1` continues to work unchanged (no regression). +- [ ] Multi-KAS TDF with mixed ML-KEM and classical KAS entries wraps + and unwraps end-to-end. +- [ ] CLI accepts the two new tokens on both + `--encapsulation-algorithm` and `--rewrap-encapsulation-algorithm`. +- [ ] Unit tests for `mlKemEncapsulate` / `mlKemDecapsulate` validate + against FIPS 203 known-answer vectors. +- [ ] Default algorithm change to `mlkem:768` lands behind a clearly + documented gate; this ticket does not flip the default unless KAS + support is confirmed. +- [ ] `make license-check`, `make lint`, `make test` all pass. diff --git a/web-app/package-lock.json b/web-app/package-lock.json index bbee888fd..9b2575ca1 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -704,6 +704,62 @@ "@emnapi/runtime": "^1.7.1" } }, + "node_modules/@noble/ciphers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.2.0.tgz", + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/post-quantum": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@noble/post-quantum/-/post-quantum-0.6.1.tgz", + "integrity": "sha512-+pormrDZwjRw05U8ADK4JpHejo87+gBd+muRBB/ozztH5yhDLMDF4jHQWN3NQQAsu1zBNPWTG0ZwVI0CR29H0A==", + "license": "MIT", + "dependencies": { + "@noble/ciphers": "~2.2.0", + "@noble/curves": "~2.2.0", + "@noble/hashes": "~2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -771,11 +827,12 @@ "node_modules/@opentdf/sdk": { "version": "0.20.0", "resolved": "file:../lib/opentdf-sdk-0.20.0.tgz", - "integrity": "sha512-9htqIO+bVhZF+mKJcoEf0LxOmIvYkjXu/giiuYMIvY2oLX+op7OG6qXTIup8/TpGMSb3iKIMosz7kNJ+8XSbSA==", + "integrity": "sha512-nQdpcnR4fq0xxp01cYAGtORzTJqG0/5ZWNZBJKNn2Kf53L/Im+Pnj6zwgUBQzpiaAkPdHP6PR/anXx7gqzWTJQ==", "license": "BSD-3-Clause-Clear", "dependencies": { "@connectrpc/connect": "^2.0.2", "@connectrpc/connect-web": "^2.0.2", + "@noble/post-quantum": "^0.6.1", "buffer-crc32": "^1.0.0", "jose": "6.0.8", "json-canonicalize": "^1.0.6", diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 9d315e7b3..6c3f33c6f 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, type ChangeEvent } from 'react'; import streamsaver from 'streamsaver'; import { showSaveFilePicker } from 'native-file-system-adapter'; import './App.css'; -import { type Chunker, type Source, OpenTDF } from '@opentdf/sdk'; +import { type Chunker, type KasPublicKeyAlgorithm, type Source, OpenTDF } from '@opentdf/sdk'; import { type SessionInformation, OidcClient } from './session.js'; import { config } from './config.js'; @@ -128,6 +128,14 @@ function getDecryptReadTuningFromLocation(): DecryptReadTuning { }; } +type KaoMetadata = { + kid: string; + type: string; + url: string; + protocol: string; + wrappedKeyBytes: number; +}; + function fileNameFor(inputSource: InputSource) { if (!inputSource) { return 'undefined.bin'; @@ -242,6 +250,8 @@ function App() { const [downloadState, setDownloadState] = useState(); const [inputSource, setInputSource] = useState(); const [sinkType, setSinkType] = useState('file'); + const [encapAlgorithm, setEncapAlgorithm] = useState('ec:secp256r1'); + const [kaoMetadata, setKaoMetadata] = useState(); const [streamController, setStreamController] = useState(); useEffect(() => { @@ -396,6 +406,7 @@ function App() { dpopKeys: oidcClient.getSigningKey(), }); setDownloadState('Encrypting...'); + setKaoMetadata(undefined); let f: FileSystemFileHandle | undefined; const downloadName = `${inputFileName}.tdf`; if (sinkType === 'fsapi') { @@ -408,6 +419,7 @@ function App() { cipherText = await client.createZTDF({ autoconfigure: false, source: { type: 'stream', location: source.pipeThrough(progressTransformers.reader) }, + wrappingKeyAlgorithm: encapAlgorithm, }); } catch (e) { setDownloadState(`Encrypt Failed: ${e}`); @@ -493,6 +505,23 @@ function App() { // strictly be smaller than the input file. try { const reader = client.open({ source }); + try { + const manifest = await reader.manifest(); + const kaos = manifest.encryptionInformation.keyAccess.map((kao) => { + const wrappedKeyBytes = kao.wrappedKey ? Math.floor((kao.wrappedKey.length * 3) / 4) : 0; + return { + kid: kao.kid ?? '(no kid)', + type: kao.type, + url: kao.url, + protocol: kao.protocol, + wrappedKeyBytes, + } satisfies KaoMetadata; + }); + setKaoMetadata(kaos); + } catch (e) { + console.warn('failed to read manifest for KAO inspection', e); + setKaoMetadata(undefined); + } const plainText = await reader.decrypt(); const plainTextStream = plainText .pipeThrough(progressTransformers.reader) @@ -645,6 +674,51 @@ function App() { +
+ Encapsulation Algorithm +
+ {' '} + +
+
+ {kaoMetadata && kaoMetadata.length > 0 && ( +
+ Manifest Inspector + + + + + + + + + + + + + {kaoMetadata.map((kao, idx) => ( + + + + + + + + + ))} + +
#kidtypeprotocolwrappedKey byteskas url
{idx}{kao.kid}{kao.type}{kao.protocol}{kao.wrappedKeyBytes}{kao.url}
+
+ )} {streamController && ( diff --git a/web-app/tests/tests/roundtrip.spec.ts b/web-app/tests/tests/roundtrip.spec.ts index 6b69b69d6..025e8baa4 100644 --- a/web-app/tests/tests/roundtrip.spec.ts +++ b/web-app/tests/tests/roundtrip.spec.ts @@ -59,6 +59,53 @@ test('roundtrip ztdf', async ({ page }) => { ); }); +// ML-KEM-512 is intentionally omitted: the platform KAS only supports mlkem:768 +// and mlkem:1024 (see lib/ocrypto/key_type.go), so a 512 roundtrip cannot rewrap. +for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { + const expectedKid = algorithm.replace(':', ''); + test(`roundtrip ztdf with ${algorithm}`, async ({ page }) => { + page.on('download', (download) => + download.path().then((r) => console.log(`Saves ${download.suggestedFilename()} as ${r}`)) + ); + + await authorize(page); + await loadFile(page, 'README.md'); + await page.locator('#encapAlgorithm').selectOption(algorithm); + + const downloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#encryptButton').click(); + const download = await downloadPromise; + expect(download.suggestedFilename()).toContain('README.md.'); + const cipherTextPath = await download.path(); + expect(cipherTextPath).toBeTruthy(); + if (!cipherTextPath) { + throw new Error(); + } + + await page.locator('#clearFile').click(); + await loadFile(page, cipherTextPath); + const plainDownloadPromise = page.waitForEvent('download'); + await page.locator('#fileSink').click(); + await page.locator('#decryptButton').click(); + const download2 = await plainDownloadPromise; + expect(download2.suggestedFilename()).toContain('.decrypted'); + const plainTextPath = await download2.path(); + if (!plainTextPath) { + throw new Error(); + } + const text = await readFile(plainTextPath, 'utf8'); + expect(text, `Looking for clone command in ${plainTextPath}`).toContain( + 'try encrypting some of your own files' + ); + + // Manifest inspector should display the expected ML-KEM kid (mlkem512/768/1024) + // populated during the decrypt flow above. + await expect(page.locator('#kao-kid-0')).toHaveText(expectedKid); + await expect(page.locator('#kao-type-0')).toHaveText('mlkem-wrapped'); + }); +} + test('Remote Source Streaming', async ({ page }) => { const server = await serve('.', 8086); From 908d9b15ed56bed5d3476673680d84c5d88a2204 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 15:24:38 -0400 Subject: [PATCH 2/5] fix(sdk): enforce requested wrapping key algorithm (DSPX-3229) Signed-off-by: Dave Mihalcik --- lib/src/access.ts | 5 ++++- lib/tdf3/src/client/index.ts | 2 +- lib/tests/mocha/encrypt-decrypt.spec.ts | 11 +++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 29cb2fb66..78ecb46c8 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -219,7 +219,10 @@ export async function fetchKasPubKey( algorithm?: KasPublicKeyAlgorithm ): Promise { try { - return await fetchKasBasePubKey(kasEndpoint); + const baseKey = await fetchKasBasePubKey(kasEndpoint); + if (!algorithm || baseKey.algorithm === algorithm) { + return baseKey; + } } catch (e) { console.log(e); } diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 93099ed3c..2837f1a8a 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -731,7 +731,7 @@ export class Client { splitPlan.map(async ({ kas, kid, pem, sid }) => { const algorithm = await algorithmFromPEM(pem, this.cryptoService); if (wrappingKeyAlgorithm && algorithm !== wrappingKeyAlgorithm) { - console.warn( + throw new ConfigurationError( `Mismatched wrapping key algorithm: [${algorithm}] is not requested type, [${wrappingKeyAlgorithm}]` ); } diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 1c79035bf..207eb604c 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -344,6 +344,17 @@ describe('encrypt decrypt test', async function () { ] as AssertionConfig[], }); + const expectedKeyAccessType = encapKeyType.startsWith('mlkem:') + ? 'mlkem-wrapped' + : encapKeyType.startsWith('ec:') + ? 'ec-wrapped' + : 'wrapped'; + assert.equal( + encryptedStream.manifest.encryptionInformation.keyAccess[0]?.type, + expectedKeyAccessType, + `manifest should use the requested ${encapKeyType} wrapping family` + ); + // Create AssertionVerificationKeys for verification const assertionVerificationKeys: AssertionVerificationKeys = { Keys: { From eae235513d0c2165b51c35085b1c401848826004 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 15:25:55 -0400 Subject: [PATCH 3/5] fix(sdk): preserve same-KAS key alternatives (DSPX-3229) Signed-off-by: Dave Mihalcik --- lib/tdf3/src/tdf.ts | 33 +++++++---------- lib/tests/mocha/encrypt-decrypt.spec.ts | 47 +++++++++++++++++++++++++ lib/tests/mocha/unit/tdf.spec.ts | 33 +++++++++++------ lib/tests/server.ts | 10 ++++-- 4 files changed, 90 insertions(+), 33 deletions(-) diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index 5e2a9dadd..62726088c 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -723,11 +723,11 @@ export async function loadTDFStream(chunker: Chunker): Promise> { +): Record { const allowed = (k: KeyAccessObject) => allowedKases.allows(k.url); const splitIds = new Set(keyAccess.map(({ sid }) => sid ?? '')); - const accessibleSplits = new Set(keyAccess.filter(allowed).map(({ sid }) => sid)); + const accessibleSplits = new Set(keyAccess.filter(allowed).map(({ sid }) => sid ?? '')); if (splitIds.size > accessibleSplits.size) { const disallowedKases = new Set(keyAccess.filter((k) => !allowed(k)).map(({ url }) => url)); throw new UnsafeUrlError( @@ -737,23 +737,15 @@ export function splitLookupTableFactory( ...disallowedKases ); } - const splitPotentials: Record> = Object.fromEntries( - [...splitIds].map((s) => [s, {}]) + // Each split id maps to the list of KAOs that can unwrap it (in a disjunction, + // any one succeeding unwraps the split). A KAS may appear several times for + // the same split, possibly using different keys during rotation. + const splitPotentials: Record = Object.fromEntries( + [...splitIds].map((s) => [s, []]) ); for (const kao of keyAccess) { - const disjunction = splitPotentials[kao.sid ?? '']; - if (kao.url in disjunction) { - // TODO(DSPX-3454): Handle duplicate KAS URLs with different KIDs. - // Each KAO contains a KID - the function should be updated to use this - // information to differentiate between keys from the same KAS. - // Cross-SDK validation needed via xtest. - throw new InvalidFileError( - `Unable to decrypt: Multiple keys detected for Key Access Server [${kao.url}]. ` + - `Please contact your administrator.` - ); - } if (allowed(kao)) { - disjunction[kao.url] = kao; + splitPotentials[kao.sid ?? ''].push(kao); } } return splitPotentials; @@ -994,22 +986,23 @@ async function unwrapKey({ const splitPromises: Record Promise> = {}; for (const splitId of Object.keys(splitPotentials)) { const potentials = splitPotentials[splitId]; - if (!potentials || !Object.keys(potentials).length) { + if (!potentials?.length) { throw new UnsafeUrlError( `Unreconstructable key - no valid KAS found for split ${JSON.stringify(splitId)}`, '' ); } const anyPromises: Record Promise> = {}; - for (const [kas, keySplitInfo] of Object.entries(potentials)) { - anyPromises[kas] = async () => { + potentials.forEach((keySplitInfo, i) => { + const alternativeKey = `${keySplitInfo.url}#${keySplitInfo.kid ?? ''}#${i}`; + anyPromises[alternativeKey] = async () => { try { return await tryKasRewrap(keySplitInfo); } catch (e) { throw handleRewrapError(e as Error); } }; - } + }); splitPromises[splitId] = () => anyPool(poolSize, anyPromises); } try { diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 207eb604c..7fa8f1c7c 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -384,6 +384,53 @@ describe('encrypt decrypt test', async function () { } } + it('decrypts when the same KAS wraps the same split twice (DSPX-3379)', async function () { + const cipher = new AesGcmCipher(WebCryptoService); + const encryptionInformation = new SplitKey(cipher); + const key1 = await encryptionInformation.generateKey(); + const keyMiddleware = async () => ({ keyForEncryption: key1, keyForManifest: key1 }); + + const client = new Client.Client({ + kasEndpoint: kasUrl, + platformUrl: kasUrl, + allowedKases: [kasUrl], + dpopKeys: Mocks.entityKeyPair(), + clientId: 'id', + authProvider, + }); + const scope: Scope = { dissem: ['user@domain.com'], attributes: [] }; + + const encryptedStream = await client.encrypt({ + metadata: Mocks.getMetadataObject(), + wrappingKeyAlgorithm: 'rsa:2048', + offline: true, + scope, + keyMiddleware, + splitPlan: [ + { kas: kasUrl, sid: '1' }, + { kas: kasUrl, sid: '1' }, + ], + source: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(expectedVal)); + controller.close(); + }, + }), + }); + + const kaos = encryptedStream.manifest.encryptionInformation.keyAccess; + assert.equal(kaos.length, 2, 'expected two KAOs for the duplicated split'); + assert.equal(kaos[0].url, kaos[1].url); + assert.equal(kaos[0].sid, kaos[1].sid); + + const decryptStream = await client.decrypt({ + source: { type: 'stream', location: encryptedStream.stream }, + wrappingKeyAlgorithm: 'rsa:2048', + }); + const { value: decryptedText } = await decryptStream.stream.getReader().read(); + assert.equal(new TextDecoder().decode(decryptedText), expectedVal); + }); + it('encrypt-decrypt with system metadata assertion', async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index d6aa2f616..eb08c2c27 100644 --- a/lib/tests/mocha/unit/tdf.spec.ts +++ b/lib/tests/mocha/unit/tdf.spec.ts @@ -276,8 +276,8 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); expect(result).to.deep.equal({ - split1: { 'https://kas1': keyAccess[0] }, - split2: { 'https://kas2': keyAccess[1] }, + split1: [keyAccess[0]], + split2: [keyAccess[1]], }); }); @@ -291,8 +291,8 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); expect(result).to.deep.equal({ - split1: { 'https://kas1': keyAccess[0] }, - split2: { 'https://kas2': keyAccess[1] }, + split1: [keyAccess[0]], + split2: [keyAccess[1]], }); }); @@ -309,17 +309,28 @@ describe('splitLookupTableFactory', () => { ); }); - it('should throw for duplicate URLs in the same splitId', () => { + it('should keep duplicate URLs in the same splitId as alternatives (DSPX-3379)', () => { const keyAccess: KeyAccessObject[] = [ { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, - { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, // duplicate URL in same splitId + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, ]; const allowedKases = new OriginAllowList(['https://kas1']); - expect(() => TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.throw( - InvalidFileError, - 'Unable to decrypt: Multiple keys detected for Key Access Server [https://kas1]. Please contact your administrator.' - ); + expect(TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.deep.equal({ + split1: [keyAccess[0], keyAccess[1]], + }); + }); + + it('should keep same-KAS different-kid entries as alternatives (DSPX-3379)', () => { + const keyAccess: KeyAccessObject[] = [ + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k1' }, + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas', kid: 'k2' }, + ]; + const allowedKases = new OriginAllowList(['https://kas1']); + + expect(TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.deep.equal({ + split1: [keyAccess[0], keyAccess[1]], + }); }); it('should handle empty keyAccess array', () => { @@ -352,7 +363,7 @@ describe('splitLookupTableFactory', () => { const result = TDF.splitLookupTableFactory(keyAccess, new OriginAllowList(allowedKases)); expect(result).to.deep.equal({ - '': { 'https://kas1': keyAccess[0] }, + '': [keyAccess[0]], }); }); }); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 83e78a343..0965c91c1 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -3,7 +3,7 @@ import { createServer, IncomingMessage, RequestListener } from 'node:http'; import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'; import { base64 } from '../src/encodings/index.js'; -import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; +import { encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; import { getMocks } from './mocks/index.js'; import { keyAgreement, pemPublicToCrypto } from '../src/crypto/index.js'; import { generateRandomNumber } from '../src/crypto/generateRandomNumber.js'; @@ -51,6 +51,9 @@ import { } from '../src/platform/kas/kas_pb.js'; const Mocks = getMocks(); +const KAS_RSA_PRIVATE_KEY = DefaultCryptoService.importPrivateKey!(Mocks.kasPrivateKey, { + usage: 'encrypt', +}); function range(start: number, end: number): Uint8Array { const result = []; @@ -337,7 +340,10 @@ const kas: RequestListener = async (req, res) => { const dekab = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey); dek = Binary.fromArrayBuffer(dekab); } else { - dek = await decryptWithPrivateKey(Binary.fromArrayBuffer(wk), Mocks.kasPrivateKey); + dek = await DefaultCryptoService.decryptWithPrivateKey( + Binary.fromArrayBuffer(wk), + await KAS_RSA_PRIVATE_KEY + ); } if (isMLKEMClient && clientMlKemLevel !== undefined && clientKeyRaw !== undefined) { const { cipherText, sharedSecret } = From c8c754aad9820028d8855b5960a8e92127b09dbd Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 15:28:06 -0400 Subject: [PATCH 4/5] fix(web-app): report exact wrapped key sizes (DSPX-3229) Signed-off-by: Dave Mihalcik --- web-app/src/App.tsx | 7 ++++++- web-app/tests/tests/roundtrip.spec.ts | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 6c3f33c6f..e21a16f85 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -136,6 +136,11 @@ type KaoMetadata = { wrappedKeyBytes: number; }; +function decodedBase64Length(value: string): number { + const paddingLength = value.endsWith('==') ? 2 : value.endsWith('=') ? 1 : 0; + return Math.floor((value.length * 3) / 4) - paddingLength; +} + function fileNameFor(inputSource: InputSource) { if (!inputSource) { return 'undefined.bin'; @@ -508,7 +513,7 @@ function App() { try { const manifest = await reader.manifest(); const kaos = manifest.encryptionInformation.keyAccess.map((kao) => { - const wrappedKeyBytes = kao.wrappedKey ? Math.floor((kao.wrappedKey.length * 3) / 4) : 0; + const wrappedKeyBytes = kao.wrappedKey ? decodedBase64Length(kao.wrappedKey) : 0; return { kid: kao.kid ?? '(no kid)', type: kao.type, diff --git a/web-app/tests/tests/roundtrip.spec.ts b/web-app/tests/tests/roundtrip.spec.ts index 025e8baa4..b2f2e63a6 100644 --- a/web-app/tests/tests/roundtrip.spec.ts +++ b/web-app/tests/tests/roundtrip.spec.ts @@ -63,6 +63,7 @@ test('roundtrip ztdf', async ({ page }) => { // and mlkem:1024 (see lib/ocrypto/key_type.go), so a 512 roundtrip cannot rewrap. for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { const expectedKid = algorithm.replace(':', ''); + const expectedWrappedKeyBytes = algorithm === 'mlkem:768' ? 1158 : 1638; test(`roundtrip ztdf with ${algorithm}`, async ({ page }) => { page.on('download', (download) => download.path().then((r) => console.log(`Saves ${download.suggestedFilename()} as ${r}`)) @@ -103,6 +104,7 @@ for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { // populated during the decrypt flow above. await expect(page.locator('#kao-kid-0')).toHaveText(expectedKid); await expect(page.locator('#kao-type-0')).toHaveText('mlkem-wrapped'); + await expect(page.locator('#kao-wrapped-bytes-0')).toHaveText(String(expectedWrappedKeyBytes)); }); } From fae87de9f6fba4c75ac0b1fc501e5b7fad8f7635 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 17 Jul 2026 16:50:21 -0400 Subject: [PATCH 5/5] revert: enforce requested wrapping key algorithm Reverts 908d9b15ed56; WellKnown base-key selection is advisory. Signed-off-by: Dave Mihalcik --- lib/src/access.ts | 5 +---- lib/tdf3/src/client/index.ts | 2 +- lib/tests/mocha/encrypt-decrypt.spec.ts | 11 ----------- 3 files changed, 2 insertions(+), 16 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 78ecb46c8..29cb2fb66 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -219,10 +219,7 @@ export async function fetchKasPubKey( algorithm?: KasPublicKeyAlgorithm ): Promise { try { - const baseKey = await fetchKasBasePubKey(kasEndpoint); - if (!algorithm || baseKey.algorithm === algorithm) { - return baseKey; - } + return await fetchKasBasePubKey(kasEndpoint); } catch (e) { console.log(e); } diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index 2837f1a8a..93099ed3c 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -731,7 +731,7 @@ export class Client { splitPlan.map(async ({ kas, kid, pem, sid }) => { const algorithm = await algorithmFromPEM(pem, this.cryptoService); if (wrappingKeyAlgorithm && algorithm !== wrappingKeyAlgorithm) { - throw new ConfigurationError( + console.warn( `Mismatched wrapping key algorithm: [${algorithm}] is not requested type, [${wrappingKeyAlgorithm}]` ); } diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 7fa8f1c7c..9677746cc 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -344,17 +344,6 @@ describe('encrypt decrypt test', async function () { ] as AssertionConfig[], }); - const expectedKeyAccessType = encapKeyType.startsWith('mlkem:') - ? 'mlkem-wrapped' - : encapKeyType.startsWith('ec:') - ? 'ec-wrapped' - : 'wrapped'; - assert.equal( - encryptedStream.manifest.encryptionInformation.keyAccess[0]?.type, - expectedKeyAccessType, - `manifest should use the requested ${encapKeyType} wrapping family` - ); - // Create AssertionVerificationKeys for verification const assertionVerificationKeys: AssertionVerificationKeys = { Keys: {