diff --git a/.github/workflows/roundtrip/init-temp-keys.sh b/.github/workflows/roundtrip/init-temp-keys.sh index 9a2d296a6..7b340f843 100755 --- a/.github/workflows/roundtrip/init-temp-keys.sh +++ b/.github/workflows/roundtrip/init-temp-keys.sh @@ -68,6 +68,15 @@ 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) +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..0b402d2f6 100644 --- a/.github/workflows/roundtrip/opentdf.yaml +++ b/.github/workflows/roundtrip/opentdf.yaml @@ -10,6 +10,9 @@ logger: # password: changeme services: kas: + preview: + ec_tdf_enabled: true + mlkem_tdf_enabled: true keyring: - kid: e1 alg: ec:secp256r1 @@ -21,6 +24,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 +97,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/asn1.ts b/lib/tdf3/src/crypto/core/asn1.ts new file mode 100644 index 000000000..7000512fe --- /dev/null +++ b/lib/tdf3/src/crypto/core/asn1.ts @@ -0,0 +1,74 @@ +// Generic ASN.1 / DER primitives shared by the SPKI and ML-KEM codecs. +// Definite-length BER/DER only; that is all X.509 SubjectPublicKeyInfo needs. + +export function encodeLength(len: number): Uint8Array { + if (!Number.isSafeInteger(len) || len < 0) { + throw new Error(`Invalid ASN.1 length: ${len}`); + } + 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: ${len}`); +} + +export 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}`); + } + // Reject truncated encodings and non-minimal long form (leading zero octet). + if (offset + 1 + numOctets > bytes.length || bytes[offset + 1] === 0) { + throw new Error('Invalid ASN.1 length encoding'); + } + let length = 0; + for (let i = 0; i < numOctets; i++) { + length = (length << 8) | bytes[offset + 1 + i]; + } + // Long form must not encode a value that fits in short form. + if (length < 0x80) { + throw new Error('Non-canonical DER length encoding'); + } + return { length, bytesConsumed: 1 + numOctets }; +} + +export type Tlv = { + /** Identifier octet (tag). */ + tag: number; + /** Index of the first content byte. */ + contentStart: number; + /** Index one past the last content byte. */ + contentEnd: number; + /** Start of the following TLV; equals contentEnd for definite-length values. */ + next: number; +}; + +/** + * Read a single definite-length TLV (tag-length-value) starting at `offset`. + * Throws if the declared length runs past the end of `bytes`. + */ +export function readTlv(bytes: Uint8Array, offset: number): Tlv { + if (offset >= bytes.length) { + throw new Error(`ASN.1 read past end of buffer at offset ${offset}`); + } + const tag = bytes[offset]; + const { length, bytesConsumed } = decodeLength(bytes, offset + 1); + const contentStart = offset + 1 + bytesConsumed; + const contentEnd = contentStart + length; + if (contentEnd > bytes.length) { + throw new Error('ASN.1 TLV length exceeds buffer size'); + } + return { tag, contentStart, contentEnd, next: contentEnd }; +} + +export function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} diff --git a/lib/tdf3/src/crypto/core/key-format.ts b/lib/tdf3/src/crypto/core/key-format.ts index 836bb0c13..0d1b073dd 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, @@ -19,7 +21,15 @@ import { guessCurveName, toJwsAlg, } from '../../../../src/crypto/pemPublicToCrypto.js'; -import { unwrapKey, wrapPrivateKey, wrapPublicKey } from './keys.js'; +import { + unwrapKey, + wrapMlKemPublicKey, + unwrapMlKemKey, + wrapPrivateKey, + wrapPublicKey, +} from './keys.js'; +import { bytesEqual, readTlv } from './asn1.js'; +import { decodeMlKemSpkiDer, encodeMlKemSpkiDer, ML_KEM_OID_ARC_PREFIX } from './mlkem-asn1.js'; import { rsaOaepSha1 } from './rsa.js'; /** @@ -51,9 +61,6 @@ export async function extractPublicKeyPem( throw new ConfigurationError('Input must be a PEM-encoded certificate or public key'); } -const SUPPORTED_EC_CURVES = ['P-256', 'P-384', 'P-521'] as const; -type SupportedEcCurve = (typeof SUPPORTED_EC_CURVES)[number]; - /** * Decode base64url string and return byte length. * Uses the existing base64 decoder which handles both standard and URL-safe encoding. @@ -65,16 +72,63 @@ function base64urlByteLength(base64url: string): number { return base64Decode(padded).byteLength; } +// DER OID *content* bytes (tag/length stripped), as returned by readTlv. These are +// the byte twins of the hex OID constants in pemPublicToCrypto.ts. +const RSA_OID = Uint8Array.of(0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01); // 1.2.840.113549.1.1.1 +const EC_OID = Uint8Array.of(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01); // 1.2.840.10045.2.1 (id-ecPublicKey) +const P256_OID = Uint8Array.of(0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07); // 1.2.840.10045.3.1.7 +const P384_OID = Uint8Array.of(0x2b, 0x81, 0x04, 0x00, 0x22); // 1.3.132.0.34 +const P521_OID = Uint8Array.of(0x2b, 0x81, 0x04, 0x00, 0x23); // 1.3.132.0.35 + /** - * Extract EC curve from a public key by parsing ASN.1 OIDs. - * Reuses the existing guessCurveName function that checks for curve OIDs. + * Read a SubjectPublicKeyInfo's algorithm OID (and, for EC, the following curve + * parameter OID) directly from its DER bytes: + * + * SEQUENCE { AlgorithmIdentifier SEQUENCE { OID algorithm, ANY parameters }, BIT STRING key } + * + * Reading the OID from its exact structural position — rather than substring + * matching hex-encoded key bytes — avoids incidental and nibble-misaligned + * matches against key material. */ -function extractEcCurveFromPublicKey(keyData: ArrayBuffer): SupportedEcCurve { - // Convert to hex for OID parsing - const hexKey = hexEncode(keyData); - // Use existing OID parser (returns 'P-256', 'P-384', or 'P-521') - const curveName = guessCurveName(hexKey); - return curveName as SupportedEcCurve; +function readSpkiAlgorithm(der: Uint8Array): { algorithmOid: Uint8Array; curveOid?: Uint8Array } { + // readTlv bounds each TLV against `der`, but not against its parent SEQUENCE. + // Validate containment at every level so malformed input cannot be classified + // as a supported key from its OIDs alone. + const spki = readTlv(der, 0); + if (spki.tag !== 0x30 || spki.next !== der.length) { + throw new ConfigurationError('Invalid SPKI: malformed outer SEQUENCE'); + } + + const algId = readTlv(der, spki.contentStart); + if (algId.tag !== 0x30 || algId.next > spki.contentEnd) { + throw new ConfigurationError('Invalid SPKI: malformed AlgorithmIdentifier'); + } + + const oid = readTlv(der, algId.contentStart); + if (oid.tag !== 0x06 || oid.next > algId.contentEnd) { + throw new ConfigurationError('Invalid SPKI: missing algorithm OID'); + } + const algorithmOid = der.subarray(oid.contentStart, oid.contentEnd); + + // AlgorithmIdentifier parameters follow the OID. For EC keys this is the named + // curve OID; for RSA it is NULL; for ML-KEM it is absent. + let curveOid: Uint8Array | undefined; + if (oid.next < algId.contentEnd) { + const param = readTlv(der, oid.next); + if (param.next > algId.contentEnd) { + throw new ConfigurationError('Invalid SPKI: AlgorithmIdentifier parameter exceeds bounds'); + } + if (param.tag === 0x06) curveOid = der.subarray(param.contentStart, param.contentEnd); + } + + // The subjectPublicKey BIT STRING must follow AlgorithmIdentifier and consume + // the remainder of the outer SEQUENCE exactly (no trailing bytes). + const subjectPublicKey = readTlv(der, algId.next); + if (subjectPublicKey.tag !== 0x03 || subjectPublicKey.next !== spki.contentEnd) { + throw new ConfigurationError('Invalid SPKI: missing or malformed subjectPublicKey'); + } + + return { algorithmOid, curveOid }; } /** @@ -113,11 +167,19 @@ export async function parsePublicKeyPem(pem: string): Promise { throw new ConfigurationError('Input must be a PEM-encoded public key or certificate'); } - const keyData = base64Decode(removePemFormatting(publicKeyPem)); + const der = new Uint8Array(base64Decode(removePemFormatting(publicKeyPem))); + const { algorithmOid, curveOid } = readSpkiAlgorithm(der); + + // ML-KEM: WebCrypto has no support (as of 2026). Route by the id-alg-ml-kem arc, + // then let decodeMlKemSpkiDer validate structure/length and report the level. + if (algorithmOid.length === 9 && bytesEqual(algorithmOid.subarray(0, 8), ML_KEM_OID_ARC_PREFIX)) { + const { level } = decodeMlKemSpkiDer(der); + return { algorithm: `mlkem:${level}` as const, pem: publicKeyPem }; + } - // Try RSA first - use JWK export to get modulus size - try { - const modulusBits = await extractRsaModulusBitLength(keyData); + if (bytesEqual(algorithmOid, RSA_OID)) { + // Use JWK export to read the modulus size. + const modulusBits = await extractRsaModulusBitLength(der.buffer); let algorithm: PublicKeyInfo['algorithm']; if (modulusBits < MIN_ASYMMETRIC_KEY_SIZE_BITS) { throw new ConfigurationError( @@ -131,25 +193,12 @@ export async function parsePublicKeyPem(pem: string): Promise { throw new ConfigurationError(`Unsupported RSA key size: ${modulusBits} bits`); } return { algorithm, pem: publicKeyPem }; - } catch (e) { - // If it's our own ConfigurationError, rethrow - if (e instanceof ConfigurationError) { - throw e; - } - // Not an RSA key, try EC next } - // Try EC - parse curve from OID - try { - const detectedCurve = extractEcCurveFromPublicKey(keyData); - const curveMap = { - 'P-256': 'ec:secp256r1', - 'P-384': 'ec:secp384r1', - 'P-521': 'ec:secp521r1', - } as const; - return { algorithm: curveMap[detectedCurve], pem: publicKeyPem }; - } catch { - // Not a valid EC key + if (bytesEqual(algorithmOid, EC_OID) && curveOid) { + if (bytesEqual(curveOid, P256_OID)) return { algorithm: 'ec:secp256r1', pem: publicKeyPem }; + if (bytesEqual(curveOid, P384_OID)) return { algorithm: 'ec:secp384r1', pem: publicKeyPem }; + if (bytesEqual(curveOid, P521_OID)) return { algorithm: 'ec:secp521r1', pem: publicKeyPem }; } throw new ConfigurationError('Unable to determine public key algorithm - unsupported key type'); @@ -225,12 +274,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 +444,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..57a97c433 --- /dev/null +++ b/lib/tdf3/src/crypto/core/mlkem-asn1.ts @@ -0,0 +1,197 @@ +// 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 +// } + +import { decodeLength, encodeLength } from './asn1.js'; + +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. Exported so SPKI classifiers can +// recognise the id-alg-ml-kem arc before delegating to decodeMlKemSpkiDer. +export const ML_KEM_OID_ARC_PREFIX = Uint8Array.of(0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x04); + +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..dfea68a09 --- /dev/null +++ b/lib/tdf3/src/crypto/core/mlkem.ts @@ -0,0 +1,60 @@ +import { ml_kem768, ml_kem1024 } from '@noble/post-quantum/ml-kem.js'; +import { + type KeyPair, + type PrivateKey, + type PublicKey, + type SymmetricKey, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { + unwrapMlKemKey, + wrapMlKemPrivateKey, + wrapMlKemPublicKey, + wrapSymmetricKey, +} 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); +} diff --git a/lib/tdf3/src/crypto/declarations.ts b/lib/tdf3/src/crypto/declarations.ts index 84c3c4307..91ec76b5b 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; }; /** @@ -429,4 +470,37 @@ export type CryptoService = { * @throws ConfigurationError if not supported by the implementation */ mergeSymmetricKeys: (shares: SymmetricKey[]) => Promise; + + // === Optional post-quantum capability (ML-KEM, NIST FIPS 203) === + // + // These are OPTIONAL so that custom CryptoService implementations (e.g. + // HSM-backed) predating post-quantum support keep compiling. Implementations + // that omit them are rejected at runtime — SDK call sites guard on presence and + // throw ConfigurationError — mirroring the optional importPrivateKey?/ + // exportPrivateKeyPem? members above. + + /** + * 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; }; diff --git a/lib/tdf3/src/crypto/index.ts b/lib/tdf3/src/crypto/index.ts index cb24fa03e..158a24d88 100644 --- a/lib/tdf3/src/crypto/index.ts +++ b/lib/tdf3/src/crypto/index.ts @@ -29,6 +29,7 @@ import { rsaPkcs1Sha256, } from './core/rsa.js'; import { deriveKeyFromECDH, generateECKeyPair } from './core/ec.js'; +import { generateMlKemKeyPair, mlKemDecapsulate, mlKemEncapsulate } from './core/mlkem.js'; import { sign, verify } from './core/signing.js'; import { exportPrivateKeyPem, @@ -78,6 +79,7 @@ export { generateECKeyPair, generateKey, generateKeyPair, + generateMlKemKeyPair, generateSigningKeyPair, hex2Ab, hmac, @@ -86,6 +88,8 @@ export { importSymmetricKey, jwkToPublicKeyPem, mergeSymmetricKeys, + mlKemDecapsulate, + mlKemEncapsulate, parsePublicKeyPem, publicKeyPemToJwk, randomBytes, @@ -113,12 +117,15 @@ export const DefaultCryptoService: CryptoService = { generateECKeyPair, generateKey, generateKeyPair, + generateMlKemKeyPair, generateSigningKeyPair, 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..2f8f66cad 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,99 @@ 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 + if (!this.cryptoService.mlKemEncapsulate) { + throw new ConfigurationError('CryptoService does not support ML-KEM (mlKemEncapsulate)'); + } + 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..750ad0344 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`); } @@ -721,8 +738,8 @@ export function splitLookupTableFactory( ); } // 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. + // 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, []]) ); @@ -776,6 +793,14 @@ async function unwrapKey({ } else if (wrappingKeyAlgorithm === 'rsa:2048' || !wrappingKeyAlgorithm) { // generateKeyPair() returns opaque keys ephemeralEncryptionKeys = await cryptoService.generateKeyPair(); + } else if (isMlKemKeyAlgorithm(wrappingKeyAlgorithm)) { + if (!cryptoService.generateMlKemKeyPair) { + throw new ConfigurationError( + 'CryptoService does not support ML-KEM (generateMlKemKeyPair)' + ); + } + const level = mlKemAlgorithmToLevel(wrappingKeyAlgorithm); + ephemeralEncryptionKeys = await cryptoService.generateMlKemKeyPair(level); } else { throw new ConfigurationError(`Unsupported wrapping key algorithm [${wrappingKeyAlgorithm}]`); } @@ -889,6 +914,54 @@ 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); + + if (!cryptoService.mlKemDecapsulate) { + throw new ConfigurationError( + 'CryptoService does not support ML-KEM (mlKemDecapsulate)' + ); + } + 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, @@ -931,9 +1004,6 @@ async function unwrapKey({ } 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 () => { try { diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index 9d4806277..9677746cc 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); @@ -377,12 +387,8 @@ describe('encrypt decrypt test', async function () { 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', @@ -410,7 +416,6 @@ describe('encrypt decrypt test', async function () { source: { type: 'stream', location: encryptedStream.stream }, wrappingKeyAlgorithm: 'rsa:2048', }); - const { value: decryptedText } = await decryptStream.stream.getReader().read(); assert.equal(new TextDecoder().decode(decryptedText), expectedVal); }); diff --git a/lib/tests/mocha/unit/crypto-di.spec.ts b/lib/tests/mocha/unit/crypto-di.spec.ts index 3911d8884..473491a3e 100644 --- a/lib/tests/mocha/unit/crypto-di.spec.ts +++ b/lib/tests/mocha/unit/crypto-di.spec.ts @@ -135,6 +135,17 @@ 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); + }, }; const mockAuthProvider: AuthProvider = { @@ -304,6 +315,17 @@ 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); + }, }; const mockAuthProvider: AuthProvider = { diff --git a/lib/tests/mocha/unit/crypto/mlkem-kat-vectors.ts b/lib/tests/mocha/unit/crypto/mlkem-kat-vectors.ts new file mode 100644 index 000000000..9b8c493db --- /dev/null +++ b/lib/tests/mocha/unit/crypto/mlkem-kat-vectors.ts @@ -0,0 +1,34 @@ +// FIPS 203 (ML-KEM) known-answer test vectors for decapsulation. +// +// Source: NIST ACVP-Server, gen-val/json-files/ML-KEM-encapDecap-FIPS203-tr1/ +// internalProjection.json (testType VAL, function="decapsulation", +// keyFormat="expanded"). Spec: +// https://pages.nist.gov/ACVP/draft-celi-acvp-ml-kem.html +// +// dk = decapsulation (private) key, c = KEM ciphertext, +// k = expected 32-byte shared secret. All values hex-encoded. + +export interface MlKemDecapKat { + level: 768 | 1024; + tcId: number; + dk: string; + c: string; + k: string; +} + +export const ML_KEM_DECAP_KATS: MlKemDecapKat[] = [ + { + level: 768, + tcId: 106, + dk: 'cb1b26da54a45f29c5b3c978fef5a310011031c11e7b22408d904b54368c6a94673be80a59244478ca553243b97f6c861d799bc710a40f18a10fe152031aceaac714f7f7c790406ac51b3d9370715ba69f7a2328afe762bda8c6bdca7a1a7ccedb93641302c1af70643e146332c00516a772d1a35b3867270440760505be5f131cac50bb8e20aa90d11108e508be39bcb3e302fc0c15be858af5a0c6a073a848ecb9dd07bddcc51555f64db760cee4075dcd74185e651f1a88aab91a41074c507af979be35200ef45fe99bba00003c4f3c260c98b271317a8057c147139e7d0686bd748a443b7df0b20144ecb5bc3564cbbaa50990642fd80dca196df57004528aa3126959ff6c06ae20067961c9aaa3ad61f1c3c3b48f19006eb9f139e3c359e0ca8acf2655ddbb05f4808ac56724fbd1ca1755b58cdc44bc73555218714a2a591ee6688b536eff927a07e69faa03937384017183cd98faac6d0956dea4b535ec7b9aca074db8669b1a99cabc21a4d76370c9b24b117ee0c5b1ec5230fb988751149d9e9a80786b454236c63000813817a473681355d09a2114cbd2e0c49c3877865a5259a231b2d90ea6437de4e8ab444b1128553c2001146be8abda7b0cf8473b07d59cc5e0aab7dc3d02097533a52496f721b785c8cfe26dde78050312c482a773725473a48c710aecaeef3cb6d0ca2b4797a45fdc55f514477c37473daa3422d87a5be0c5a0b22394d739279a48757cb0ab561932089d25e19facf39e1bfc52242207dc206c06f867d8f529ed592c5771aa6a6205f470773197b1858818ddf54b09b029573801a5ba04768a92ae55549548025214740c956391e44184f9b466e0ba4e59b1d0bb64e6d56749d9466569369f197c78c83381cb1fde7ca067e4b0780239e16b905a754875a66fa37ba2dd55777c474e62a85cc28584a525b19b18cc3570b73a9ac9686bbcc992bb6bba016a1696586270a7a5066f4b34a8c17f4dac37a9e34c5a51024a373f593b0f41e1c157c2bd8ebc9e31565f5a50b3802b91289a2b2e30b4b12631b1349462046cf527298ff01b6a006b23b179dac41e70e92173aca3e833cf4f5822dd65711ff93c113b2c5cfa1f6f62b1fce09601376e9fc45f1c172a92270d400486894281eac1a89f491ca9e01805f65a6878a9c6d9c22841b78a1a96fa63af42551c5a80860c490edac9cac91777b7175fe9d832fc843da077301cd6bf6c667b43d9173a1133e33a1e2e9ac7dfc286007dabf5e43421f02170bb96d9480b950b60635a97ae359cc2625b85eb995edbabe43950af60af288a6481963dbd43ced59a039b896fab9912fa817580a7be47371cf2615ab9b5839fa40160c67576090b03950e3e5a0e2cba6b3f656f2da21a495b7ad4cb049a9a44979a863b54532a2940df87cee33c1d9a8185262ab97b21a810b0a7cb3560728b6e1d33366a79b3c0e2be9d43b14388776d025d0e9472174a17cad18bff8701181cb4475320e6f8c0cdca18481b6a8a5043d6548343800f10756a253026691c5e3af8b10e39283f8c05815134e985aa029c6c9b5198d849a8b49572f4cc7445580035340fe2f2c856908358178454f7358d25c67239c33de96577877c54906f8dfa0bd9908165bc436df1c2d2c64f74b41badca90630b89e2f2931166b81864883cfc671ac9b769ec5e2b0a11a9b9676250b597e20c8694ac38b8049dfa8fff42c7b98a0cd7040958f653e60cc1d10443dfc75ed666abee498ff3a649de5c14e185a70cac4260a6061a733bf7b88784ac92dc9ab44b6a87c2e148b8eb45dce43a2d248153a4914ab8464ee66ad2a6cbdfc1bda9600becd36c9db197fbc8c19a7b28f7e24ed2298bc0e6cf70f91ddfd3061468ca7410c50ee18b33390bc1b01f2a94012b2a6c445730bc5bae5b832e029546f9744649e386e8bc8e86472a7bb7c15177b3e9a13edd64acad45663846ac1291bd49a09072165f766bbd06f626da9614097a4aa90b0ebd14cd570207265070b3935400255bb17ca887a820f588199c45a28434740499cd39708e4cf41cee507d4ad99bbf6106fcf948cab2933b08491ed9ba3850bc5c403bcc9884ea02a0df51b84ea38172574408f255fc21363e709496a054954719200488c8fb0937f436a04a94c337130427c8330158f1d82695e17148367020d679cc87cbc3961989a461fff6575f4404eb34090a78bdbef7cbc046af3d96474871aac05a425e595aa7cb547a4251b42133abdb07b3337a723bc1f9ec9fcda8397f6122b9b974086046fd9b333d4a4ffd731f13562989aa5738359041d458df76806722beb05ac67ad45b9d461bd10c8f91c8ca8d1763349347d17418d68732d7296a2090a4c9d42b91f270411abc76e0bee0d645c3629d81a8a23c240dd4685f8ceb37c2fbc033d943a0197a03a056fe29469c547048d11f327b9b3d7261ad43171e9bad8d344f45eb44cdc9703871b8c722220a8c6b58077885f6c068cb914ee71224fca60a5ccaa2d38fa6fba2e0641650a0066af10336557b91f581b58b4c5436380dfa6f28870758f67286a89cccc15cb96bce414345e9454eb0192827987a76a22969d5af40cb4a37d0a3298ca0a3e3b96a3b60fff6b8d9322a278c404da85678e10055f547e8f2a9d020541cb27f6839a98c766fafbb83c3c70ea14c01f800abac32c926e2838970ab502615769903f5ec49dda5bad4b67976562c5119299db8b07a7a9d6704a483897cd9340701752a9330055e258a776a6033ec75dfb5b9f892670bc073ff0326158b2a07dbaa1fe23d42590635426d45c9674679be7d2c5b02c701e1d99b2b38508932cc0be814bcf27fc44b0fdb321deba7892e06c3c50274a257bae04838ea320a557189f4b75098495439f6a46ddc1336856d574317deaba48a119e00e5adcc5c73320295c7294a90d20aa16245f5c3546e0371e34b0f79bab945a349a1e41eeaa207a74a5524f0a25d9b392470831f2a54f42680a9234c6f438df8258950eb992b811216e4771b9b3778129bcb70921d4b5e7649b0fcf823419c65c2abcf3d7c673fb8a5e9c4aacdb08238a309af964a4b535992f92e579aae142c8f94e4aedc822df142abc6dc21357a4da10a7da098a5ab0c2995718cd4901cb1dc890f71748f5a7b18ea9432aa40a7f589dbf3c0d5c0b675b94522209b8ba55e978531e88684842b75305b36e1c80f1ad2946ea69ae06ac6d229240144ad2c7a5312219a53611977922e6993c4e0bd332bb5ef897f0d0b660858166201a51f65adb611c09dbdc8b0a9a6e08ae43aaa54fe74cb50edabedf14a8334543a05ac90e3792ee5a163576d7352cc94a45d81d3979f93d92b763296c87ed4ae5ca410b6427e32bd7f91127e46fb769a56', + c: '581c2217fe81a8b24d936f2b3a149ee003de882bd6ebbc3cf9b3b631d81597772264dfc2fb5b4e850d37cdb02619f0612458cbd6f702c73a382babb929438c5a505298ff074c9c7eb80dbab1666786050bcee784782a7b8e3071816e7388c13f7e7fcaa24710a1a289ff67d527e1f4e78323aa624c265d094cc193c26a4b1570c5d01a7de3e40ab1dc0abd7b2d7eabfac2bfb00e5b2034eea05e9ff21cb56be4c93e9b91a90dc04e4d575074443ca73c4aa93853e0a6b6e48c370889de8cc185e4ddc88d3ef7275199bf0b932d3e0268a40aff468c668b84fdf9fc6348ee1b604253a8d0532ce7ab7a055b824ce3a3dd850589b86f0582160073354aaa2e51e16f428d52f212549934748e8ba534a99801aaa9796e72e6eef7fbd61a0b70f5465049f0caf7122e09f39de97ed06bc3a6a62d22694a44aa9899f2e6fc8464491a748ad455f250ddc83827184e34ca9fae3baee916ce25acfbbd310df13c6e42e7a185015c5bb406c944e6622596fdaaa1b915b89c3e0c30b80d2a399e642a2ceb6e20eb666dc312a385ff1cf7a08c11ffd3c2b489de1d1a1aec834049d689352daf9810ae770bfc69bf8b7cfced106d2c731a6b12627a154a3d20ef0743fb0da606c52dbc39b7d0adfadf19462e705a30905b27c1edb269c4d8c08d564a7c97d4fc690a514a5799ff534ec274ed449d83628baa04ebe18f1b319e93495e2f983b857e6169f459935d5bd30abc776cf2b8ef1c81b5719977c78c666a4029c4d8e1ad3ce5ee5d97a31981f6d5e6b484b31ca5d484805ffb8bdff7c8a34723a8355a1afea41cb35bf5cd591dd302d68824e34f344a71e18800848ff59e81629de46bb203cf9abbfb3a982b68ddb6aefc43e1dd95808d75800c4fb64feb0a9205a1e8000c45a99308e3624ccfb2bb6cca049a13ebd1ef69edb6740906dd76e2bf51d0560d6a7950467d956981b78c247f7a2d656f9f82d8772a5782f6a2dab3d397163ea755d9a349ec35198440fe7d521cef0890cda03838261322c171253d602c1d787687f44d06cc92ffb4dc3bb9de98a73fe0b0455540e4cc4d11f48e2a298e7a4f72f10cd24c059005da100d178fbcf6c5186c04fff15b05b685a20ab1bb49c57d4f532525e20fce22a8d2ceadf703cca238667f2ea01772b2638f9ca087ae9c43f983dd8fefae1b037838e3a644d97f7517ad1131a90131d3d993ef920881ce9c120d4dbd3d0618db99bd9a0bb1adef3f072ef691848791f8e229b27b3591550c696d3a9f3c831d1789d9dfd9737e47a3821bda26b35e28fddb9558d0474760ae349d42e7176682b7b5f58cfe5d8aff53b47fdaced11ae182eef29b4c2d9836e0e41313096e20f6a57e2d4ed3d8247b54792905369bab27f2332c922132af2f8504bf748b756d63bb34c040fb2db1675d67a0be933bb8ce9c32500ae34aa86c524062b6983fdb863abb0a7592f293b92e351d564da83f91b6e48c16ea30e78ecc070a704e3d092c6a966b76244857e20d9d73cb383c32ba5857e49731f012a4', + k: 'cd31c7c656e26c9a83ee74358fa8ef4a5151a9fc737e781abdc31a1738af1a7c', + }, + { + level: 1024, + tcId: 126, + dk: '9956beade66c27eb8b1cf81e49fc398967c0e835736faa7a2cc3c74e95b55d5297964927a5f1514d38140d54b10c80bfe4aa78a13c7790679c58a6385ae5cf178b1ca5b9acfb150ca2a955732bb0cb91c9f96c5c83fcb58861748d139a093b40e7f33838336b380414689225bde3a9a4a8b2dfba2ed9954a09d505d717a10cdbab98a8cb6c6a812fe8a96c8bbc8e2445fb2483bef47637451518163bf6040b696b4ebf70a10b548f2e5a0ff69015fa3401c08ab9ee258ce781bb3aab9a103a3cefcb0ef672299590cbb75429c9322ee064502edcb3a9c1736ab90a02d34fd83874bfb4b68b93465707a5ee498223f98ffce6193314c3549142fd6974cd048a8d30bd36b62f8ac93d8d24bbdd099320f166281035f21ace2aabbef2056d667c2d5528cef9a72eb7d33954c5c4c833b4697a2ed4a63996cb113c078f48fa646594babfaa43ee266258ab6af29c8f53508a00b8b066175e89e03946b81716c058cdeb7f5c774e9f1bbbcaa2cbeb4c3568747e8f872467e86d42249ad1c6973bf49747ac687dd34654d32e66893f5310b358aba211466bea1a263daa0af88931725b3c4efa9a9c17b849718ee8c7832fd5cd364679dfd81c08fabc331b72978996fe16af98eb5c33b2b051279ec6021581b48289b10c4dfc9e5df14b6ec8ab55280945fc16354a594e502b3e32bcd8c20cd7966baf8759c1e59b3876c3141a1838e69c78686984ec393381bc548c0850927dc72abc7d8a0deb3632bde8b916624f29420a836c01b34c0f5653133e7c1b61ac3275a83ccf2b931fb85a04427eb7b46906e4651153aea4cc43f788b133081161c749eb8a016816400eea2a26f50c53e714048835c6e214d2cb5d9d9572a343b24f5746a92a2b78204404c96043683ea37cb178577646e6c2d31799241370e8622e4d42677c016dd27b01ea87cf89f7ab5a7682996832179c3b6370b511c10efb53291ed823f5032bcdc13fec7b4e5684c36145cb98ebb46602a5b7f41be49a8f2ef9af2c700f6db109316ab0d70b10545482976ca1a1e4a8bc272c1fd3a3b36c2970a03b12921650b1cfee1574c254a6941a456f546eb55804ea58b3a2690703baa7bc7849c8138830ba7a70a17898e2b9e1260d1b1b30c661c8113a35bb91ae732a38b6e1723fa962e0baa313d9888ce35f6ca73e3e9111f9b58241d21c16e377585a21b0c6934e064f943061d32b49c8697fe560c022f0baacc699719c591f657226b728d8cb6b17096d37237e9c812c86147fbceaa0b260c967d9b3c5b65caa82c1d12c5fd05c3fcc55725631ba1ea938bfa72fddc050e6b262f8135bcf3ac03e2160901031c38c02f4ba28c2111c95b51dab357665dcb41a7abcc3739c1605525ff4c2f72accc1729596eb7f5fb81415ca087db1618632998d2189c3c87f9db63532633fbcb25f3799bd608aac06740f974b99a867b423b40cc1598f9c387b9017cc63182a1d708234d666bb0caff5457b0c5571af913998214e05593527eb86c9d42700bd21e68cc52c20b17ef6067e977f2da9a4def2aa2e444317a0ad8a7123790a5f77a4b1e0f3246fd93a7a7503b87325bd23ab21622f1a336a13eb587f234aa0a68802ec80e74c9847d20f9072c956e2766f6b361a66729f9b41b8fa9edb0a9009cc229f5422e58525e5b71aa16c09ee0380d0861b9e845f25578031159235a47cffb5cd73375788335e8f5ca7ab917443a74cfd51686c9654f1c72567714ffa6a4dfdd884b96a94f7928a5013a4a92312710a4196c6c6c7f95556b9a679e6ad3df1b61e29267cab7b7fa8cfc59556297272d181883fd2b1070148d05a6cc32794a4f70c33453a58c97bccb42df0b549b3c4534c8c79a0a7ada4aa6f5b112e0c0bc106228a1fa6630a05737294268ed79d66ca02a26b487bd94e71d02169878df5151b9734a7731acc2995281fa914b2722090b2ae346442a9600c8fd70ae7027cefc074e1e3951361296e350db7459ea1e23e1b17adc7a93003e524b5489296e2734948ce4e4247ec100045fcc8f08c67385bc1d7cc0ec1427476d0aa34168f0a8020e9c50c0381354684553c72b68e18aeb6f60f4a0a7ade8212877b3d16f3bca3fc402156b13dd8458b2a2d9f743cbe21b68d5168012737fa2708b0b8145326a2067b429b268d22801f50c5870f2837c76c5e38957cad9195e241bd41545dfc7b281ae77c43e6b4077a284a7683253072c1db12856cc19a5a3d43b975782ac7ab552c56499c8bdcbd79ec735d215f8d70c4bf960d06304b65737acec67e93b2a2b5e30bc051bb781a16c8f252e6b830287395dd55886e016165d6964f177b424bce9383af23957d45634005e77bc0db4cc42266b27c3b4f5183307b932640856d82af0395ad8f44706000c23213019f680f9e39160d4c63e0d493d23c50b123bd76e102993a40de09b579503727a75d7ef88d50660c09eb4d6d85ac65410d08f833f36ac7b1542361a4a1a04c1a4e5a64bc3396b4e2ae494160bdf8269b35b850ec0aee83164e564509a80bd28576e6686aae71108c930a8885115e337d36e9ac41bb2a2abab1b79cb86d96afb4315a6fc3c9d703bb88b22e7eec694f261383d38a72273e176c28d26728ca882936f00cef978fb6868280e385d199553f19b131d4151ca822fa24642b85403ee056df9987dbc2a542fa0a497308cb6459af89621c54b85cd940f1b510fb950f933956b49492d5f63194651c4bb220719c362232bb332212175c01b8a0694990663f2bbeead8744b15367a078234112a768c92695882f9a1662010b6d01473c07505ec85250b5cbc3e76005bdc9148bc0dfce760ca9560a573a84c77c33e229918368ab0f20d8a7378e6fb9a262b9023ac7564613d79fb1de7418459f10f964676aad953d09a11e8ac7e251b2bb94845f7d013d596cda7a09d9379bf31c4557b101e2e9a61154b5cc2f5c6a171712b88468d7b32f30b84ae372138c344769a26f048b2753147ac8b0dbee018256bb45a1632918821cce1bc77c30dabc84d703b0ec237c6aa30c932b70a96e39e3a072fef8035716b49f4b802f108becc995212361de41952fef93bd6bac1b64a97a53022c0426bf53c321fb741125c9875aa6ee1939b94708ac743613a40a20c3a68112233efe293cdb59aba296c4c7a718e38a555842dc4803d326769d820bb9de999c8b43807fa4e1e5a902af94c084a7397ea70afca8505e8430da4467d263ead5c62652565b9aca4bd180750816ff507623bfb591746b1ac435b6da46dd54b9ec72079841b28c47421097526a3e883244a468fb00005ea5bff17c96f650464074b59696d23e4b29654186f3b0f16ec3252729ffc358f629834e99519c3c62bbf8991b5e46c05a83a6bb28b7c11ab1f309158f5a1b05a4a13186bd9fc223d830291c10cb2b88fce5408471c56cb6ca90a052152a760e75b3f190a5e23c81352f67549c065a309332f50bd071294109358e8e312525cc6f1a0ce884510bfe865cbdaab818b6b71e57c84ac9d1d0c63ab082d5decabd1cc0de18700874396e9716af214059ca76c3fbaabd0cb364b448540a575d07c77b6d566ece9b8e4aa24ddda07b3e7a7f0f8b3e051815e587bac565f6912c0023b560788b1ed61b790b479456b72283b7ec7121cbd557de1097a132185001b49e9600413039054a2b7bd91513615997ff801b568776fd1adcefa833708bf753ab4cad9511ea23322342811db7741524f0948128f158143377e91a56244e33573e6273a16b486622ac5daca6d2bcf540b2e9899a9191597f7fb1d953333233399412834ce4b68038855deba50c3707ba1f46ff6962e87fc88d5a79b0414ca70a7579bf71f53250976dc3ea9a596a4771d8e3cbcd85173962b80d1b915548379a7782868ea4a159c0316372c2199a5a0a66d72b430b41a21714149f938cb6c54113b2910961b8eb9bacf25c53ddc02706d6c8488164d04bb134a0564177c3158fb954676a4254c9f5d15b09ac95b8bd21da1d95deb13c10220a668a68401a46bb6a4b028ac8308771ad7f7af3e9885b789076cd85a7cc934702417769b928df71ede6a456ed344b85b7bd3095702710ee272ac33c14ec61a7eefe94300929173d56c33084b726649c9a937af1873e2516a78976e0e50cfc9e5814ad052dea21b32631805926d90e211326061671848aba537aef64ffc2bb0341a13ea400238c33ae33c204e30aee98c73a49625f2a17b6304ae761a333d3590d06521ddb9cf35d6c164d336bb162db92c4bc6672591c22b5b501dfd81c3ac7282dd22bfd06410d34cc5d844a06488a5d3ca7741d0b084a61727680d593436caa29756f0f5584c56f86b1d1b3bbb9334fc18f39b99fec354a79aeeaf52c88a20ba9f51b25518e584e0d5d551bc5533a5a37a5ad627bcb229aa97bd6f3b4c549386e2291ebcd05bcb5b970af01798e48a803d66990342cfe6f39df2d448f8b3f046ac', + c: '1273ec61f9279ad6b41d92e0479799634766d3ff69ea31fee01ac20b64c1122230de85caf332a8a7d63d7bb09a76337912d3a591a7765f5e1b2e25344614a24ba1a80805e3b5be50a62705521a7d390a4ddd8f4e259206bf1b6e997e0c9ed18269a6917af2a7820dd259b041a94c823f5a4ec74b621793436a0b22c7418665bb57ed0ebbe9a05709675dce6116ec0eaa2f1e365c206d0a2134de82abe5d5f86fe215ef8b06cb840bf0102a40b939edf0e6b47f6d2ba0ea61f20dcd96b724dba6ec9ee25d0aca12cd067d3c128eef1391f3f8bd141cb25023363a49152ab27fce68dc66eb4759ef4d1a220e89ce72aadefaf2b1d01aed12a9510ff48e6d30bf59df44e8728b4ed35af914df2bcf88c8cb92636ebd78baf554b1cb0c3ce70fbf30c1685f70fc6d441bf3479aa7eea55aab3c452a2cac78eeca9fcf5b744de25bb78e34e8666e284f6c37a0b1e4f1c49fe3df929fa53a9425f9143f0ab17dee0f153785cce31824a91a4fda68dbbbe91011d34aa3e06c5832333d93dc8fa90262af104b8c8ef4d0fc1c9a64da993f551cab89021ab86bc8fed785985ee1124a04ea0c285e65c896f31a603fef97a0bb694055d4455af01bca787e519709605d72666d98623b5b86a860e493c7c4b97ca92708326b89cf4e8c31ca41d020f04486dc7fab07c3ce84fb81429a7280decd30ebb7af2ec037af2e3cbd3fddecc7cefd5190502360aa24a05315c95390b8137092bb01d10acba9620656be02ee6696a2a476b3f12e72bd46d4c1e292485a811888e86cb837cb69a1a47370f6c07b53ed8304fc7bad96099386a4841a48d2d6eef4ad4111c2e5a5b1647c7456343bf1b02a9b51d05dd8a9d3d5e817a0759394d3fb079c7356b4b7a970089698ac46a7afd4962bc9cd1a71122a24ad00db3639df53dd6cf6841cbefede7a6d0e68bbb18a69e6be638f83232edbe5fc047a8495abc3f60c927f2bc797210cfe052c899667b12fef564880598ed0d348206780e84ceb0b642434ac2c685df08f96ce01dfd0c147239b2cfa41b55e905d2ee9311e8956be1ea1179b131c50d91e814ef40712e702a05a521f351c966c47a18338a4b9a355abb2f58b60a90be7c8f5d02af44b54581041a0061cf95fe6ed00bef631005614cfecef2cb4849506ad1dfd42bef46788d9ae7c98d1f2d0a27df65eedb4e9e3eeaf8b753ff78461463b9034ebe294c5285e0b4efbb694fcaa3347597ebfb16ce3818a5b12f0ec1cad9bf110a43763cf3e14636b6addc369df7a8c0aca337da8a65ba6e1d4ab0af6a5d3622e525790e52112ad4ba71b4162ce85f0b45416cda911c2c9bd9a1ec891009a8ca47332c60e2cfc480072a89e65ad1961ae0cb04e815027145d92de4ced072adf49febab6ce2689c5813a494c31cfe7894161d1f9d8946bb33b8eff4f53240d5572b635139990f1a618cc19373f55bc0e208183290628936797062658e0f2a0c5ef0fb01c80867499df9f718d415e5b899633739a6e11b5973bf43141f8f9036898f05820fd67ac3c399f0c58abca3e6e34afa27fd128f4bec0d6f37eee4466461bea62e844761597add495b4b441c61f0a6a3c18a422368b04d967ae84d2e5b7c5b53c2c42d4d07274217c59f2f29978caada809f91601b89061194262386f986e933463e011526403ae2f39f472ead4c33c5f50061311b989a3925480f72e3baa5e02d32b5bb68019bc42bd306a06c15dccd25a334be18947698c8751b44578987d79400b5a4ed18bc7c5d00b4e861da70a443cdd9573c612837d5b255532616157510fe91cc1752233a8dbad16b1992feedef499f669c6b5653700c263446591e5f229166d537bb119a90c7acd9123ecf9bdfed0d169a74f816dec0d624a8c723eb637403303ea9768f34fd33750503f02dbcfb43789cfdec0d707c60cba1f25477320dd4031fcfc9f5edd71bd7ac3be3edb5798e34adc1ac3c1c43eddda34ca8fa7ec36086df00ef8ee64c75715a31e61d695dabbc872249a6c0cf9bf82c41fb49595ad1792efa82244fc801dbdfae2f9f79ec7b8dff6b321634f3c60f5a3f4a9cff5bbc91b9bf69791ca3e584160894552752eda593f407ecdc4fa858eec43768e23bbdc0c4260652ee0501972ded35e0e7a25286d7e458d092c46ff4f62c93a2e5ef67b88fc69b7b4f0570473f6fd9d9b58d6881135cbb2c4deab0a1f707020803f94', + k: '93d64623fd963ad3b6567a7d08f36b3386dec87ec8fb9a08fe17360a0caf35b6', + }, +]; 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..5df695446 --- /dev/null +++ b/lib/tests/mocha/unit/crypto/mlkem.spec.ts @@ -0,0 +1,141 @@ +import { expect } from 'chai'; +import { + generateMlKemKeyPair, + mlKemEncapsulate, + mlKemDecapsulate, +} 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 { wrapMlKemPrivateKey } from '../../../../tdf3/src/crypto/core/keys.js'; +import { hex } from '../../../../src/encodings/index.js'; +import { ML_KEM_DECAP_KATS } from './mlkem-kat-vectors.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('FIPS 203 decapsulation known-answer tests (NIST ACVP)', () => { + for (const kat of ML_KEM_DECAP_KATS) { + it(`ML-KEM-${kat.level} recovers the expected shared secret (ACVP tcId ${kat.tcId})`, async () => { + const dk = new Uint8Array(hex.decodeArrayBuffer(kat.dk)); + const ct = new Uint8Array(hex.decodeArrayBuffer(kat.c)); + const expected = new Uint8Array(hex.decodeArrayBuffer(kat.k)); + + const privateKey = wrapMlKemPrivateKey(dk, kat.level); + const sharedSecret = await mlKemDecapsulate(privateKey, ct); + + expect(unwrapSymmetricKey(sharedSecret)).to.deep.equal(expected); + }); + } + }); + + 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..aa6c2c9d0 --- /dev/null +++ b/lib/tests/mocha/unit/mlkem-key-access.spec.ts @@ -0,0 +1,140 @@ +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; + +// Compile-time proof that ML-KEM support is an OPTIONAL CryptoService capability: +// `undefined` is assignable to the member type only when it is declared optional. +// If these become required again, assigning `true` to a `false` type fails the build. +const _mlKemEncapsulateIsOptional: undefined extends CryptoService['mlKemEncapsulate'] + ? true + : false = true; +const _mlKemDecapsulateIsOptional: undefined extends CryptoService['mlKemDecapsulate'] + ? true + : false = true; +const _generateMlKemKeyPairIsOptional: undefined extends CryptoService['generateMlKemKeyPair'] + ? true + : false = true; +void _mlKemEncapsulateIsOptional; +void _mlKemDecapsulateIsOptional; +void _generateMlKemKeyPairIsOptional; + +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); + }); + + it('write() throws ConfigurationError when the CryptoService lacks ML-KEM support', async () => { + // A custom (e.g. HSM-backed) service that predates post-quantum support: it + // implements importPublicKey but omits the optional ML-KEM methods entirely. + const noMlKemService: CryptoService = { + async importPublicKey(): Promise { + return { _brand: 'PublicKey', algorithm: 'mlkem:768', mlKemLevel: 768 }; + }, + } as unknown as CryptoService; + + const mlKemWrapped = new MlKemWrapped(url, kid, publicKey, metadata, noMlKemService, sid, alg); + const dek = await importSymmetricKey(new Uint8Array([1, 2, 3, 4, 5])); + + let err: unknown; + try { + await mlKemWrapped.write(policy, dek, encryptedMetadataStr); + } catch (e) { + err = e; + } + expect(err).to.be.instanceOf(ConfigurationError); + }); +}); diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index 28919cc2d..eb08c2c27 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'); + } }); }); @@ -296,28 +312,23 @@ describe('splitLookupTableFactory', () => { 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' }, // same KAS + split + { sid: 'split1', type: 'remote', url: 'https://kas1', protocol: 'kas' }, ]; 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({ + expect(TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.deep.equal({ split1: [keyAccess[0], keyAccess[1]], }); }); - it('should keep same-KAS different-kid entries in the same splitId (DSPX-3379)', () => { + 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']); - const result = TDF.splitLookupTableFactory(keyAccess, allowedKases); - - expect(result).to.deep.equal({ + expect(TDF.splitLookupTableFactory(keyAccess, allowedKases)).to.deep.equal({ split1: [keyAccess[0], keyAccess[1]], }); }); diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 6f389b201..b18308db2 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,17 +1,54 @@ 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 { 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. +// This configures the mock KAS backend itself, so an unsupported value is a +// server-misconfiguration: fail fast rather than silently advertising EC and +// letting a roundtrip test exercise the wrong key-wrap family. +const SUPPORTED_BASE_KEY_ALGS = ['ec:secp256r1', 'rsa:2048', 'mlkem:768', 'mlkem:1024'] as const; +const BASE_KEY_ALG = process.env.BASE_KEY_ALG || 'ec:secp256r1'; +if (!(SUPPORTED_BASE_KEY_ALGS as readonly string[]).includes(BASE_KEY_ALG)) { + throw new Error( + `Unsupported BASE_KEY_ALG [${BASE_KEY_ALG}]; expected one of ${SUPPORTED_BASE_KEY_ALGS.join(', ')}` + ); +} import { create, toJsonString, fromJson } from '@bufbuild/protobuf'; import { ValueSchema } from '@bufbuild/protobuf/wkt'; @@ -23,6 +60,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 = []; @@ -95,12 +135,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 +257,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 +292,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"}'); @@ -256,9 +349,58 @@ 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 (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 +441,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 +584,27 @@ 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 if (BASE_KEY_ALG === 'rsa:2048') { + publicKey = { algorithm: 'rsa:2048', kid: 'r1', pem: Mocks.kasPublicKey }; + } else { + // ec:secp256r1 — the only remaining value (validated at import time). + 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..9309dcbe9 --- /dev/null +++ b/spec/DSPX-3229.md @@ -0,0 +1,299 @@ +--- +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 + encapsulation key as PEM-wrapped SPKI (the same envelope as + `KasPublicKeyInfo.publicKey`, see the rewrap-request section below) in + `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..e21a16f85 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,19 @@ function getDecryptReadTuningFromLocation(): DecryptReadTuning { }; } +type KaoMetadata = { + kid: string; + type: string; + url: string; + protocol: string; + 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'; @@ -242,6 +255,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 +411,7 @@ function App() { dpopKeys: oidcClient.getSigningKey(), }); setDownloadState('Encrypting...'); + setKaoMetadata(undefined); let f: FileSystemFileHandle | undefined; const downloadName = `${inputFileName}.tdf`; if (sinkType === 'fsapi') { @@ -408,6 +424,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 +510,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 ? decodedBase64Length(kao.wrappedKey) : 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 +679,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..c017d7fb7 100644 --- a/web-app/tests/tests/roundtrip.spec.ts +++ b/web-app/tests/tests/roundtrip.spec.ts @@ -59,6 +59,55 @@ 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(':', ''); + 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}`)) + ); + + 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 (mlkem768/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'); + await expect(page.locator('#kao-wrapped-bytes-0')).toHaveText(String(expectedWrappedKeyBytes)); + }); +} + test('Remote Source Streaming', async ({ page }) => { const server = await serve('.', 8086);