diff --git a/lib/package-lock.json b/lib/package-lock.json index 13615d059..ec45960f3 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", @@ -1618,6 +1619,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 d6f1df069..f27e273dc 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 5f2b11cd0..c81317d08 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -91,11 +91,12 @@ export type KasPublicKeyAlgorithm = | 'ec:secp256r1' | 'ec:secp384r1' | 'ec:secp521r1' + | 'mlkem:768' | 'rsa:2048' | 'rsa:4096'; export const isPublicKeyAlgorithm = (a: string): a is KasPublicKeyAlgorithm => { - return a === 'ec:secp256r1' || a === 'rsa:2048'; + return a === 'ec:secp256r1' || a === 'mlkem:768' || a === 'rsa:2048'; }; export const keyAlgorithmToPublicKeyAlgorithm = (k: CryptoKey): KasPublicKeyAlgorithm => { @@ -142,6 +143,8 @@ export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => { return 'ES384'; case 'ec:secp521r1': return 'ES512'; + case 'mlkem:768': + return 'ML-KEM-768'; default: throw new Error(`unsupported public key algorithm: ${a}`); } diff --git a/lib/src/crypto/enums.ts b/lib/src/crypto/enums.ts index 24797de36..70be7aa18 100644 --- a/lib/src/crypto/enums.ts +++ b/lib/src/crypto/enums.ts @@ -3,6 +3,7 @@ export enum AlgorithmName { ECDSA = 'ECDSA', ES256 = 'ES256', HKDF = 'HKDF', + MLKEM_768 = 'ML-KEM-768', RSA_OAEP = 'RSA-OAEP', RSA_PSS = 'RSA-PSS', } diff --git a/lib/src/crypto/mlkem.ts b/lib/src/crypto/mlkem.ts new file mode 100644 index 000000000..71fd498b1 --- /dev/null +++ b/lib/src/crypto/mlkem.ts @@ -0,0 +1,67 @@ +import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; +import { decodeArrayBuffer as base64Decode } from '../encodings/base64.js'; +import { encodeArrayBuffer as hexEncode } from '../encodings/hex.js'; + +export const MLKEM768_OID_HEX = '0609608648016503040402'; +export const MLKEM768_EK_BYTES = 1184; +export const MLKEM768_CT_BYTES = 1088; + +// Bytes before the raw key in a well-formed ML-KEM-768 SPKI: +// 4 outer SEQUENCE header + 13 AlgorithmIdentifier + 4 BIT STRING header + 1 unused-bits = 22 +const MLKEM768_SPKI_EK_OFFSET = 22; + +/** + * Extract the raw 1184-byte encapsulation key from an ML-KEM-768 SPKI blob. + */ +export function extractMLKEM768EkFromSpki(spkiBytes: ArrayBuffer): Uint8Array { + const hex = hexEncode(spkiBytes); + if (!hex.includes(MLKEM768_OID_HEX)) { + throw new Error('Not an ML-KEM-768 SPKI public key'); + } + const view = new Uint8Array(spkiBytes); + if (view.length !== MLKEM768_SPKI_EK_OFFSET + MLKEM768_EK_BYTES) { + throw new Error(`Invalid ML-KEM-768 SPKI length: ${view.length}`); + } + return view.slice(MLKEM768_SPKI_EK_OFFSET); +} + +/** + * Parse an ML-KEM-768 PEM public key and return the raw 1184-byte encapsulation key. + */ +export function parseMLKEM768PublicKeyPem(pem: string): Uint8Array { + const b64 = pem.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/g, ''); + const spkiBytes = base64Decode(b64); + return extractMLKEM768EkFromSpki(spkiBytes); +} + +/** + * Encapsulate using ML-KEM-768: returns 1088-byte ciphertext and 32-byte shared secret. + */ +export function mlkem768Encapsulate(ek: Uint8Array): { + cipherText: Uint8Array; + sharedSecret: Uint8Array; +} { + return ml_kem768.encapsulate(ek); +} + +/** + * AES-256 Key Wrap (RFC 3394) of raw key bytes using the given 32-byte wrapping key. + * Returns 40 bytes for a 32-byte input key. + */ +export async function aesKwWrap( + keyBytes: Uint8Array, + wrappingKeyBytes: Uint8Array +): Promise { + const wrappingKey = await crypto.subtle.importKey('raw', wrappingKeyBytes, 'AES-KW', false, [ + 'wrapKey', + ]); + const keyToWrap = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const wrapped = await crypto.subtle.wrapKey('raw', keyToWrap, wrappingKey, 'AES-KW'); + return new Uint8Array(wrapped); +} diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index f98c7e720..0d7c38443 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -733,6 +733,7 @@ export class Client { switch (algorithm) { case 'rsa:2048': case 'rsa:4096': + case 'mlkem:768': type = 'wrapped'; break; case 'ec:secp384r1': diff --git a/lib/tdf3/src/crypto/core/key-format.ts b/lib/tdf3/src/crypto/core/key-format.ts index a8beb736f..b041bf87e 100644 --- a/lib/tdf3/src/crypto/core/key-format.ts +++ b/lib/tdf3/src/crypto/core/key-format.ts @@ -95,6 +95,28 @@ async function extractRsaModulusBitLength(keyData: ArrayBuffer): Promise return base64urlByteLength(jwk.n) * 8; } +// ML-KEM-768 OID: 2.16.840.1.101.3.4.4.2 encoded in DER as tag (06) + length (09) + value +const MLKEM768_OID_HEX = '0609608648016503040402'; +const MLKEM768_EK_BYTES = 1184; +// Bytes before the raw key in a well-formed ML-KEM-768 SPKI blob: +// 4 outer SEQUENCE header + 13 AlgorithmIdentifier + 4 BIT STRING header + 1 unused-bits = 22 +const MLKEM768_SPKI_EK_OFFSET = 22; + +/** + * Extract the 1184-byte ML-KEM-768 encapsulation key from a SPKI-encoded public key. + */ +export function extractMLKEM768EkFromSpki(spkiBytes: ArrayBuffer): Uint8Array { + const hex = hexEncode(spkiBytes); + if (!hex.includes(MLKEM768_OID_HEX)) { + throw new ConfigurationError('Not an ML-KEM-768 SPKI public key'); + } + const view = new Uint8Array(spkiBytes); + if (view.length !== MLKEM768_SPKI_EK_OFFSET + MLKEM768_EK_BYTES) { + throw new ConfigurationError(`Invalid ML-KEM-768 SPKI length: ${view.length}`); + } + return view.slice(MLKEM768_SPKI_EK_OFFSET); +} + /** * Import and validate a PEM public key, returning algorithm info. * Uses JWK export for robust key parameter detection. @@ -112,6 +134,12 @@ export async function parsePublicKeyPem(pem: string): Promise { const keyData = base64Decode(removePemFormatting(publicKeyPem)); + // Check for ML-KEM-768 by OID before attempting WebCrypto import + const keyHex = hexEncode(keyData); + if (keyHex.includes(MLKEM768_OID_HEX)) { + return { algorithm: 'mlkem:768', pem: publicKeyPem }; + } + // Try RSA first - use JWK export to get modulus size try { const modulusBits = await extractRsaModulusBitLength(keyData); diff --git a/lib/tdf3/src/crypto/declarations.ts b/lib/tdf3/src/crypto/declarations.ts index ba0c789dd..60c905f39 100644 --- a/lib/tdf3/src/crypto/declarations.ts +++ b/lib/tdf3/src/crypto/declarations.ts @@ -29,7 +29,8 @@ export type KeyAlgorithm = | 'rsa:4096' | 'ec:secp256r1' | 'ec:secp384r1' - | 'ec:secp521r1'; + | 'ec:secp521r1' + | 'mlkem:768'; /** * Options for key generation and import. @@ -156,7 +157,13 @@ export type HkdfParams = { */ export type PublicKeyInfo = { /** Detected algorithm of the key. */ - algorithm: 'rsa:2048' | 'rsa:4096' | 'ec:secp256r1' | 'ec:secp384r1' | 'ec:secp521r1'; + algorithm: + | 'rsa:2048' + | 'rsa:4096' + | 'ec:secp256r1' + | 'ec:secp384r1' + | 'ec:secp521r1' + | 'mlkem:768'; /** Normalized PEM string. */ pem: string; }; diff --git a/lib/tdf3/src/models/key-access.ts b/lib/tdf3/src/models/key-access.ts index 6be30317a..99af504de 100644 --- a/lib/tdf3/src/models/key-access.ts +++ b/lib/tdf3/src/models/key-access.ts @@ -4,6 +4,13 @@ import type { CryptoService, KeyPair, SymmetricKey } from '../crypto/declaration import { getZtdfSalt } from '../crypto/salt.js'; import { Algorithms } from '../ciphers/index.js'; import { Policy } from './policy.js'; +import { unwrapSymmetricKey } from '../crypto/core/keys.js'; +import { + mlkem768Encapsulate, + parseMLKEM768PublicKeyPem, + aesKwWrap, + MLKEM768_CT_BYTES, +} from '../../../src/crypto/mlkem.js'; export type KeyAccessType = 'remote' | 'wrapped' | 'ec-wrapped'; @@ -152,7 +159,74 @@ export class Wrapped { } } -export type KeyAccess = ECWrapped | Wrapped; +/** + * ML-KEM-768 post-quantum key encapsulation mechanism. + * Wire format: type="wrapped", wrappedKey=base64(ciphertext[1088] || aes_kw_wrapped_dek), no ephemeralPublicKey. + */ +export class MLKEMWrapped { + readonly type = 'wrapped' as const; + keyAccessObject?: KeyAccessObject; + + constructor( + public readonly url: string, + public readonly kid: string | undefined, + public readonly publicKey: string, + public readonly metadata: unknown, + public readonly cryptoService: CryptoService, + public readonly sid?: string + ) {} + + async write( + policy: Policy, + dek: SymmetricKey, + encryptedMetadataStr: string + ): Promise { + const policyStr = JSON.stringify(policy); + + // Parse ML-KEM-768 encapsulation key from PEM + const ek = parseMLKEM768PublicKeyPem(this.publicKey); + + // ML-KEM-768 encapsulation: produces 1088-byte ciphertext and 32-byte shared secret + const { cipherText, sharedSecret } = mlkem768Encapsulate(ek); + + // AES-256 Key Wrap the DEK with the shared secret + const dekBytes = unwrapSymmetricKey(dek); + const wrappedDek = await aesKwWrap(dekBytes, sharedSecret); + + // Wire format: ciphertext || aes_kw_wrapped_dek + const wrappedKeyBytes = new Uint8Array(MLKEM768_CT_BYTES + wrappedDek.length); + wrappedKeyBytes.set(cipherText, 0); + wrappedKeyBytes.set(wrappedDek, MLKEM768_CT_BYTES); + + const policyBinding = hex.encodeArrayBuffer( + (await this.cryptoService.hmac(new TextEncoder().encode(base64.encode(policyStr)), dek)) + .buffer + ); + + this.keyAccessObject = { + type: 'wrapped', + url: this.url, + protocol: 'kas', + wrappedKey: base64.encodeArrayBuffer(wrappedKeyBytes.buffer), + encryptedMetadata: base64.encode(encryptedMetadataStr), + policyBinding: { + alg: 'HS256', + hash: base64.encode(policyBinding), + }, + schemaVersion, + }; + if (this.kid) { + this.keyAccessObject.kid = this.kid; + } + if (this.sid?.length) { + this.keyAccessObject.sid = this.sid; + } + + return this.keyAccessObject; + } +} + +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 4decf7a9b..e22d053bd 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -49,6 +49,7 @@ import { KeyAccessType, KeyInfo, Manifest, + MLKEMWrapped, Policy, SplitKey, Wrapped, @@ -280,6 +281,9 @@ export async function buildKeyAccess({ } switch (type) { case 'wrapped': + if (alg === 'mlkem:768') { + return new MLKEMWrapped(url, kid, pubKey, metadata, cryptoService, sid); + } return new Wrapped(url, kid, pubKey, metadata, cryptoService, sid); case 'ec-wrapped': return new ECWrapped(url, kid, pubKey, metadata, cryptoService, sid); diff --git a/xtest/sdk/js/cli.sh b/xtest/sdk/js/cli.sh new file mode 100755 index 000000000..891623fa1 --- /dev/null +++ b/xtest/sdk/js/cli.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Cross-platform test harness wrapper for the OpenTDF JS CLI. +# Usage: cli.sh supports +# cli.sh +set -eu + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)" + +case "${1:-}" in + supports) + case "${2:-}" in + mechanism-mlkem) + exit 0 + ;; + *) + exit 1 + ;; + esac + ;; + *) + exec node "${REPO_ROOT}/cli/bin/opentdf.mjs" "$@" + ;; +esac