From d860893bc11b8a5b3ae2213da5af28079609b53c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Fri, 8 May 2026 17:06:57 -0400 Subject: [PATCH 1/9] spec: scaffold for DSPX-3229 ml-kem kaos --- spec/DSPX-3229.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 spec/DSPX-3229.md diff --git a/spec/DSPX-3229.md b/spec/DSPX-3229.md new file mode 100644 index 000000000..1ad6d72be --- /dev/null +++ b/spec/DSPX-3229.md @@ -0,0 +1,34 @@ +--- +ticket: DSPX-3229 +title: ml-kem kaos +status: draft +authors: [dmihalcik@virtru.com] +branches: [opentdf/web-sdk:DSPX-3229-ml-kem-kaos] +prs: [] +created: 2026-05-08 +updated: 2026-05-08 +--- + +# ml-kem kaos + +## Summary +ml-kem kaos + +## Problem / Motivation +_Why does this work need to happen? What is the user/business pain?_ + +## Proposed Solution +_What will you build, at a functional level? Sketch the approach._ + +## Inputs / Outputs / Contracts +_Function signatures, data shapes, API contracts, CLI flags._ + +## Edge Cases & Constraints +_Boundary conditions, error states, performance limits, security considerations._ + +## Out of Scope +_What this work item explicitly does not cover._ + +## Acceptance Criteria +- [ ] _Clear, testable condition_ +- [ ] _…_ From 37e78bc57e146c86133644f92e8bbd40d16e7174 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 09:18:46 -0400 Subject: [PATCH 2/9] chore: Adds CLAUDE.md link to AGENTS.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) create mode 120000 CLAUDE.md 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 From 7d8eda349fa03723e8b0e6454b9fc7e55747df83 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 09:38:46 -0400 Subject: [PATCH 3/9] spec(sdk): fill DSPX-3229 spec for ML-KEM key access objects Documents the mlkem:512/768/1024 algorithm family, wrappedKey blob layout (mlkem_ct || iv || aes-256-gcm ct || tag), CryptoService additions, @noble/post-quantum dependency, and acceptance criteria covering unit tests, Playwright roundtrip, and test server changes. Co-Authored-By: Claude Opus 4.7 Signed-off-by: Dave Mihalcik --- spec/DSPX-3229.md | 189 +++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 179 insertions(+), 10 deletions(-) diff --git a/spec/DSPX-3229.md b/spec/DSPX-3229.md index 1ad6d72be..2315703f7 100644 --- a/spec/DSPX-3229.md +++ b/spec/DSPX-3229.md @@ -2,33 +2,202 @@ ticket: DSPX-3229 title: ml-kem kaos status: draft -authors: [dmihalcik@virtru.com] +authors: [dmihalcik@virtru.com, sujankota@virtru.com] branches: [opentdf/web-sdk:DSPX-3229-ml-kem-kaos] prs: [] created: 2026-05-08 -updated: 2026-05-08 +updated: 2026-05-10 --- # ml-kem kaos ## Summary -ml-kem kaos +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:512`, `mlkem:768`, `mlkem:1024`, with `mlkem:768` as the new SDK +default once the KAS rollout lands. The KAO schema is unchanged: existing +`type: 'wrapped'` is reused and the algorithm string carries the variant. +The wrappedKey blob carries the ML-KEM ciphertext prepended to the +AES-GCM-wrapped DEK. ## Problem / Motivation -_Why does this work need to happen? What is the user/business pain?_ +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 -_What will you build, at a functional level? Sketch the approach._ +- Add `mlkem:512 | 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. +- Reuse KAO `type: 'wrapped'`. The `wrappedKey` field becomes: + + ``` + base64( mlkem_ct || iv(12) || aes-256-gcm ct(32) || tag(16) ) + ``` + + where `mlkem_ct` is 768 / 1088 / 1568 bytes for ML-KEM-512 / 768 / + 1024 respectively. The shared secret returned by `ML-KEM.Decaps` is + fed through HKDF-SHA256 (using the existing `getZtdfSalt` helper) to + derive a 32-byte AES key that wraps the DEK with AES-GCM, mirroring + the ECDH wrap layout used today by `ECWrapped` + (`lib/tdf3/src/models/key-access.ts:60-68`). +- Add a sibling `MlKemWrapped` class next to `ECWrapped` and `Wrapped` + in `lib/tdf3/src/models/key-access.ts` whose `.write()` performs + encapsulation, derivation, AES-GCM wrap, and emits a KAO with + `type: 'wrapped'` and a `kid` whose KAS-side algorithm metadata is + `mlkem:`. +- Extend the algorithm switch in `lib/tdf3/src/client/index.ts:732-745` + to route `mlkem:*` to `MlKemWrapped`. +- Extend rewrap-request building in `lib/tdf3/src/tdf.ts:773-843` to + generate an ML-KEM client keypair (when configured) and send the raw + encapsulation-key bytes (base64) as `clientPublicKey`. Response + parsing dispatches on the locally-known client algorithm. +- New CryptoService methods (`lib/tdf3/src/crypto/declarations.ts:164`): + - `generateMlKemKeyPair(level: 512 | 768 | 1024): Promise` + - `mlKemEncapsulate(pk: PublicKey): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }>` + - `mlKemDecapsulate(sk: PrivateKey, ct: Uint8Array): Promise` + - `importPublicKey` / `exportPublicKeyPem` learn an `mlkem:*` raw-bytes + encoding (no standardized PEM OID in mainline yet; raw base64 with + algorithm metadata for now, SPKI when standardized). +- Implementation library: `@noble/post-quantum` (audited TS, + browser+Node, no native deps, FIPS 203 ML-KEM-512/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 -_Function signatures, data shapes, API contracts, CLI flags._ + +### New algorithm tokens +Valid wherever `KasPublicKeyAlgorithm` / `KeyAlgorithm` is accepted: +`'mlkem:512' | 'mlkem:768' | 'mlkem:1024'`. + +### Manifest contract +KAO schema is unchanged. For ML-KEM-wrapped KAOs: +- `type`: `'wrapped'` (reused) +- `kid`: identifies the KAS public key; KAS-side metadata records + algorithm = `mlkem:` +- `wrappedKey`: `base64(ct_kem || iv || aes_gcm_ct || tag)` 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?: 512 | 768 | 1024` field on the discriminated +union; they are not WebCrypto `CryptoKey`s. + +### CLI surface (`cli/src/cli.ts:460-478`) +- `--encapsulation-algorithm mlkem:512|mlkem:768|mlkem:1024` (existing + flag, expanded value set) +- `--rewrap-encapsulation-algorithm mlkem:512|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 base64 +of the raw encapsulation key bytes (no PEM wrapper) until an SPKI +encoding is standardized; the `algorithm` field disambiguates parsing. + +### Rewrap request wire (informative; authoritative spec lives in opentdf/platform) +- Client generates ML-KEM keypair locally. +- `UnsignedRewrapRequest.clientPublicKey` carries base64 raw + encapsulation key bytes. +- The platform side adds the algorithm discriminator to the rewrap + request body — captured in the platform spec, not here. ## Edge Cases & Constraints -_Boundary conditions, error states, performance limits, security considerations._ +- 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 three ML-KEM members. +- `keyAlgorithmToPublicKeyAlgorithm` (`lib/src/access.ts:101`) operates + on WebCrypto `CryptoKey`; ML-KEM keys are opaque and never go through + it. Callers must branch on `algorithm` before reaching this helper. +- 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 -_What this work item explicitly does not cover._ +- 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 -- [ ] _Clear, testable condition_ -- [ ] _…_ +- [ ] `mlkem:512 | 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 eight valid tokens and false for everything else; covered + by unit test. +- [ ] `lib/tests/mocha/encrypt-decrypt.spec.ts` exercises all three + ML-KEM sizes (`mlkem:512`, `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. +- [ ] `web-app/src` (the sample app) is updated to include ML-KEM key + configuration so the app can be used to manually verify ML-KEM + encrypt/decrypt flows. +- [ ] 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). +- [ ] `wrappedKey` blob for ML-KEM decodes as + `mlkem_ct(N) || iv(12) || aes-256-gcm ct(32) || tag(16)` and the + format is fixed by a snapshot test. +- [ ] 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 three 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. From df069a2176f540df4a1cc580aaae1be9d93c864b Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 11:34:57 -0400 Subject: [PATCH 4/9] feat(sdk): add ML-KEM key access support DSPX-3229 Signed-off-by: Dave Mihalcik --- lib/package-lock.json | 57 +++++ lib/package.json | 1 + lib/src/access.ts | 29 ++- lib/src/opentdf.ts | 4 +- lib/tdf3/index.ts | 2 + lib/tdf3/src/client/index.ts | 3 + lib/tdf3/src/crypto/core/key-format.ts | 92 ++++++- lib/tdf3/src/crypto/core/keys.ts | 61 +++++ lib/tdf3/src/crypto/core/mlkem.ts | 185 ++++++++++++++ lib/tdf3/src/crypto/declarations.ts | 63 +++-- lib/tdf3/src/crypto/index.ts | 7 + lib/tdf3/src/models/key-access.ts | 83 ++++++- lib/tdf3/src/tdf.ts | 52 ++++ lib/tests/mocha/encrypt-decrypt.spec.ts | 13 +- lib/tests/mocha/unit/access.spec.ts | 36 +++ lib/tests/mocha/unit/crypto-di.spec.ts | 28 +++ .../mocha/unit/crypto/crypto-service.spec.ts | 59 +++++ lib/tests/mocha/unit/key-access.test.ts | 48 +++- lib/tests/mocha/unit/tdf.spec.ts | 7 + lib/tests/server.ts | 229 ++++++++++++++---- lib/tests/web/roundtrip.test.ts | 36 +++ web-app/src/App.tsx | 6 + web-app/src/config.ts | 4 + 23 files changed, 1023 insertions(+), 82 deletions(-) create mode 100644 lib/tdf3/src/crypto/core/mlkem.ts create mode 100644 lib/tests/mocha/unit/access.spec.ts diff --git a/lib/package-lock.json b/lib/package-lock.json index ec810faeb..fc3fd815d 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..36957c251 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -92,10 +92,24 @@ export type KasPublicKeyAlgorithm = | 'ec:secp384r1' | 'ec:secp521r1' | 'rsa:2048' - | 'rsa:4096'; + | 'rsa:4096' + | 'mlkem:512' + | 'mlkem:768' + | 'mlkem:1024'; + +const PUBLIC_KEY_ALGORITHMS: readonly KasPublicKeyAlgorithm[] = [ + 'ec:secp256r1', + 'ec:secp384r1', + 'ec:secp521r1', + 'rsa:2048', + 'rsa:4096', + 'mlkem:512', + 'mlkem:768', + 'mlkem:1024', +]; export const isPublicKeyAlgorithm = (a: string): a is KasPublicKeyAlgorithm => { - return a === 'ec:secp256r1' || a === 'rsa:2048'; + return PUBLIC_KEY_ALGORITHMS.includes(a as KasPublicKeyAlgorithm); }; export const keyAlgorithmToPublicKeyAlgorithm = (k: CryptoKey): KasPublicKeyAlgorithm => { @@ -142,6 +156,10 @@ export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => { return 'ES384'; case 'ec:secp521r1': return 'ES512'; + case 'mlkem:512': + case 'mlkem:768': + case 'mlkem:1024': + throw new Error(`unsupported public key algorithm for JWA conversion: ${a}`); default: throw new Error(`unsupported public key algorithm: ${a}`); } @@ -161,7 +179,7 @@ export type KasPublicKeyInfo = { /** If present, an identifier which is tied to this specific key. */ kid?: string; - /** The key value, encoded within a PEM envelope */ + /** The key value, encoded as PEM for classical keys or raw base64 for ML-KEM. */ publicKey: string; }; @@ -212,7 +230,10 @@ export async function fetchKasPubKey( algorithm?: KasPublicKeyAlgorithm ): Promise { try { - return await fetchKasBasePubKey(kasEndpoint); + const baseKey = await fetchKasBasePubKey(kasEndpoint); + if (!algorithm || baseKey.algorithm === algorithm) { + return baseKey; + } } catch (e) { console.log(e); } diff --git a/lib/src/opentdf.ts b/lib/src/opentdf.ts index 738ab10dc..2015dbb8c 100644 --- a/lib/src/opentdf.ts +++ b/lib/src/opentdf.ts @@ -190,7 +190,7 @@ export type OpenTDFOptions = { authProvider?: AuthProvider; /** Default settings for 'encrypt' type requests. */ - defaultCreateOptions?: Omit; + defaultCreateOptions?: Omit; /** Default settings for 'decrypt' type requests. */ defaultReadOptions?: Omit; @@ -285,7 +285,7 @@ export class OpenTDF { /** If DPoP is enabled for this instance. */ readonly dpopEnabled: boolean; /** Default options for creating TDF objects. */ - defaultCreateOptions: Omit; + defaultCreateOptions: Omit; /** Default options for reading TDF objects. */ defaultReadOptions: Omit; /** The DPoP keys for this instance, if any. */ diff --git a/lib/tdf3/index.ts b/lib/tdf3/index.ts index 7e005194a..c23191ba1 100644 --- a/lib/tdf3/index.ts +++ b/lib/tdf3/index.ts @@ -24,6 +24,7 @@ import { type KeyPair, type KeyOptions, type KeyAlgorithm, + type MlKemLevel, type PemKeyPair, type PrivateKey, type PublicKey, @@ -65,6 +66,7 @@ export type { KeyPair, KeyOptions, KeyAlgorithm, + MlKemLevel, PemKeyPair, PrivateKey, PublicKey, diff --git a/lib/tdf3/src/client/index.ts b/lib/tdf3/src/client/index.ts index f98c7e720..1450d78c6 100644 --- a/lib/tdf3/src/client/index.ts +++ b/lib/tdf3/src/client/index.ts @@ -733,6 +733,9 @@ export class Client { switch (algorithm) { case 'rsa:2048': case 'rsa:4096': + case 'mlkem:512': + case 'mlkem:768': + case 'mlkem:1024': 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..7eb3fc4fd 100644 --- a/lib/tdf3/src/crypto/core/key-format.ts +++ b/lib/tdf3/src/crypto/core/key-format.ts @@ -9,14 +9,30 @@ import { import { ConfigurationError } from '../../../../src/errors.js'; import { formatAsPem, removePemFormatting } from '../crypto-utils.js'; import { encodeArrayBuffer as hexEncode } from '../../../../src/encodings/hex.js'; -import { decodeArrayBuffer as base64Decode } from '../../../../src/encodings/base64.js'; +import { + decodeArrayBuffer as base64Decode, + encodeArrayBuffer as base64Encode, +} from '../../../../src/encodings/base64.js'; import { exportSPKI, importX509 } from 'jose'; import { guessAlgorithmName, guessCurveName, toJwsAlg, } from '../../../../src/crypto/pemPublicToCrypto.js'; -import { unwrapKey, wrapPrivateKey, wrapPublicKey } from './keys.js'; +import { + unwrapKey, + unwrapMlKemPrivateKey, + unwrapMlKemPublicKey, + wrapMlKemPrivateKey, + wrapMlKemPublicKey, + wrapPrivateKey, + wrapPublicKey, +} from './keys.js'; +import { + isMlKemAlgorithm, + mlKemPrivateKeyAlgorithmFromLength, + mlKemPublicKeyAlgorithmFromLength, +} from './mlkem.js'; import { rsaOaepSha1 } from './rsa.js'; /** @@ -51,6 +67,10 @@ export async function extractPublicKeyPem( const SUPPORTED_EC_CURVES = ['P-256', 'P-384', 'P-521'] as const; type SupportedEcCurve = (typeof SUPPORTED_EC_CURVES)[number]; +function normalizeRawKeyString(key: string): string { + return key.replace(/[\r\n\s]/g, ''); +} + /** * Decode base64url string and return byte length. * Uses the existing base64 decoder which handles both standard and URL-safe encoding. @@ -107,7 +127,19 @@ export async function parsePublicKeyPem(pem: string): Promise { } if (!publicKeyPem.includes('-----BEGIN PUBLIC KEY-----')) { - throw new ConfigurationError('Input must be a PEM-encoded public key or certificate'); + try { + const normalized = normalizeRawKeyString(publicKeyPem); + const keyData = base64Decode(normalized); + const algorithm = mlKemPublicKeyAlgorithmFromLength(keyData.byteLength); + if (algorithm) { + return { algorithm, pem: normalized }; + } + } catch { + // Fall through to the standard validation error below. + } + throw new ConfigurationError( + 'Input must be a PEM-encoded public key, certificate, or raw ML-KEM public key' + ); } const keyData = base64Decode(removePemFormatting(publicKeyPem)); @@ -229,6 +261,17 @@ export async function importPublicKey(pem: string, options: KeyOptions): Promise // Detect algorithm from PEM; also normalises certificates → plain SPKI PEM. const keyInfo = await parsePublicKeyPem(pem); const algorithm = algorithmHint || keyInfo.algorithm; + if (isMlKemAlgorithm(algorithm)) { + const keyBuffer = base64Decode(normalizeRawKeyString(keyInfo.pem)); + const detectedAlgorithm = mlKemPublicKeyAlgorithmFromLength(keyBuffer.byteLength); + if (detectedAlgorithm !== algorithm) { + throw new ConfigurationError( + `ML-KEM public key length does not match ${algorithm}: detected ${detectedAlgorithm ?? 'unknown'}` + ); + } + return wrapMlKemPublicKey(new Uint8Array(keyBuffer), 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. const keyData = removePemFormatting(keyInfo.pem); @@ -295,13 +338,38 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis // Detect algorithm from PEM structure (similar to public key detection) // For now, use algorithmHint if provided, otherwise detect from key structure let algorithm: KeyAlgorithm; - - const keyData = removePemFormatting(pem); - const keyBuffer = base64Decode(keyData); + let keyData: string; + let keyBuffer: ArrayBuffer; if (algorithmHint) { algorithm = algorithmHint; + if (isMlKemAlgorithm(algorithm)) { + keyData = normalizeRawKeyString(pem); + keyBuffer = base64Decode(keyData); + const detectedAlgorithm = mlKemPrivateKeyAlgorithmFromLength(keyBuffer.byteLength); + if (detectedAlgorithm !== algorithm) { + throw new ConfigurationError( + `ML-KEM private key length does not match ${algorithm}: detected ${detectedAlgorithm ?? 'unknown'}` + ); + } + return wrapMlKemPrivateKey(new Uint8Array(keyBuffer), algorithm); + } } else { + if (!pem.includes('-----BEGIN PRIVATE KEY-----')) { + keyData = normalizeRawKeyString(pem); + keyBuffer = base64Decode(keyData); + const rawAlgorithm = mlKemPrivateKeyAlgorithmFromLength(keyBuffer.byteLength); + if (!rawAlgorithm) { + throw new ConfigurationError( + 'Input must be a PEM-encoded private key or raw ML-KEM private key' + ); + } + return wrapMlKemPrivateKey(new Uint8Array(keyBuffer), rawAlgorithm); + } + + keyData = removePemFormatting(pem); + keyBuffer = base64Decode(keyData); + // PKCS#8 PrivateKeyInfo embeds the same AlgorithmIdentifier OIDs as SPKI, // so guessAlgorithmName / guessCurveName work on private key bytes too. const hex = hexEncode(keyBuffer); @@ -340,6 +408,9 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis } } + keyData = removePemFormatting(pem); + keyBuffer = base64Decode(keyData); + // Determine Web Crypto algorithm and usages let cryptoAlgorithm: RsaHashedImportParams | EcKeyImportParams; let keyUsages: KeyUsage[]; @@ -396,6 +467,9 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis * Export an opaque public key to PEM format. */ export async function exportPublicKeyPem(key: PublicKey): Promise { + if (isMlKemAlgorithm(key.algorithm)) { + return base64Encode(unwrapMlKemPublicKey(key).buffer); + } const cryptoKey = unwrapKey(key); const keyBuffer = await crypto.subtle.exportKey('spki', cryptoKey); return formatAsPem(keyBuffer, 'PUBLIC KEY'); @@ -406,6 +480,9 @@ export async function exportPublicKeyPem(key: PublicKey): Promise { * ONLY USE FOR TESTING/DEVELOPMENT. Private keys should NOT be exportable in secure environments. */ export async function exportPrivateKeyPem(key: PrivateKey): Promise { + if (isMlKemAlgorithm(key.algorithm)) { + return base64Encode(unwrapMlKemPrivateKey(key).buffer); + } const cryptoKey = unwrapKey(key); const keyBuffer = await crypto.subtle.exportKey('pkcs8', cryptoKey); return formatAsPem(keyBuffer, 'PRIVATE KEY'); @@ -415,6 +492,9 @@ export async function exportPrivateKeyPem(key: PrivateKey): Promise { * Export an opaque public key to JWK format. */ export async function exportPublicKeyJwk(key: PublicKey): Promise { + if (isMlKemAlgorithm(key.algorithm)) { + throw new ConfigurationError('ML-KEM public keys do not have a JWK export format'); + } const cryptoKey = unwrapKey(key); return await crypto.subtle.exportKey('jwk', cryptoKey); } diff --git a/lib/tdf3/src/crypto/core/keys.ts b/lib/tdf3/src/crypto/core/keys.ts index cc0ff87af..af26bd487 100644 --- a/lib/tdf3/src/crypto/core/keys.ts +++ b/lib/tdf3/src/crypto/core/keys.ts @@ -1,9 +1,19 @@ import { type KeyAlgorithm, + type MlKemAlgorithm, type PrivateKey, type PublicKey, type SymmetricKey, } from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; + +function isMlKemAlgorithm(algorithm: string): algorithm is MlKemAlgorithm { + return algorithm === 'mlkem:512' || algorithm === 'mlkem:768' || algorithm === 'mlkem:1024'; +} + +function mlKemLevelFromAlgorithm(algorithm: MlKemAlgorithm) { + return Number.parseInt(algorithm.split(':')[1], 10) as 512 | 768 | 1024; +} /** * Wrap a CryptoKey as an opaque PublicKey. @@ -31,6 +41,19 @@ export function wrapPublicKey(key: CryptoKey, algorithm: KeyAlgorithm): PublicKe return result as PublicKey; } +/** + * Wrap raw ML-KEM public key bytes as an opaque PublicKey. + * @internal + */ +export function wrapMlKemPublicKey(key: Uint8Array, algorithm: MlKemAlgorithm): PublicKey { + return { + _brand: 'PublicKey', + algorithm, + mlKemLevel: mlKemLevelFromAlgorithm(algorithm), + _internal: new Uint8Array(key), + } as PublicKey; +} + /** * Wrap a CryptoKey as an opaque PrivateKey. * @internal @@ -57,11 +80,49 @@ export function wrapPrivateKey(key: CryptoKey, algorithm: KeyAlgorithm): Private return result as PrivateKey; } +/** + * Wrap raw ML-KEM private key bytes as an opaque PrivateKey. + * @internal + */ +export function wrapMlKemPrivateKey(key: Uint8Array, algorithm: MlKemAlgorithm): PrivateKey { + return { + _brand: 'PrivateKey', + algorithm, + mlKemLevel: mlKemLevelFromAlgorithm(algorithm), + _internal: new Uint8Array(key), + } as PrivateKey; +} + /** * Unwrap an opaque key to get the internal CryptoKey. * @internal */ export function unwrapKey(key: PublicKey | PrivateKey): CryptoKey { + if (isMlKemAlgorithm(key.algorithm)) { + throw new ConfigurationError(`Key algorithm ${key.algorithm} is not a WebCrypto CryptoKey`); + } + return (key as any)._internal; +} + +/** + * Unwrap an ML-KEM public key to raw bytes. + * @internal + */ +export function unwrapMlKemPublicKey(key: PublicKey): Uint8Array { + if (!isMlKemAlgorithm(key.algorithm)) { + throw new ConfigurationError(`Key algorithm ${key.algorithm} is not an ML-KEM public key`); + } + return (key as any)._internal; +} + +/** + * Unwrap an ML-KEM private key to raw bytes. + * @internal + */ +export function unwrapMlKemPrivateKey(key: PrivateKey): Uint8Array { + if (!isMlKemAlgorithm(key.algorithm)) { + throw new ConfigurationError(`Key algorithm ${key.algorithm} is not an ML-KEM private key`); + } return (key as any)._internal; } diff --git a/lib/tdf3/src/crypto/core/mlkem.ts b/lib/tdf3/src/crypto/core/mlkem.ts new file mode 100644 index 000000000..cf8ddb8a3 --- /dev/null +++ b/lib/tdf3/src/crypto/core/mlkem.ts @@ -0,0 +1,185 @@ +import { ml_kem1024, ml_kem512, ml_kem768 } from '@noble/post-quantum/ml-kem.js'; + +import { + type KeyAlgorithm, + type KeyPair, + type MlKemAlgorithm, + type MlKemLevel, + type PrivateKey, + type PublicKey, + type SymmetricKey, +} from '../declarations.js'; +import { ConfigurationError } from '../../../../src/errors.js'; +import { + unwrapMlKemPrivateKey, + unwrapMlKemPublicKey, + wrapMlKemPrivateKey, + wrapMlKemPublicKey, + wrapSymmetricKey, +} from './keys.js'; + +type MlKemImplementation = { + keygen: (seed?: Uint8Array) => { publicKey: Uint8Array; secretKey: Uint8Array }; + encapsulate: (publicKey: Uint8Array) => { cipherText: Uint8Array; sharedSecret: Uint8Array }; + decapsulate: (cipherText: Uint8Array, secretKey: Uint8Array) => Uint8Array; +}; + +export const ML_KEM_PUBLIC_KEY_LENGTHS: Record = { + 512: 800, + 768: 1184, + 1024: 1568, +}; + +export const ML_KEM_PRIVATE_KEY_LENGTHS: Record = { + 512: 1632, + 768: 2400, + 1024: 3168, +}; + +export const ML_KEM_CIPHERTEXT_LENGTHS: Record = { + 512: 768, + 768: 1088, + 1024: 1568, +}; + +const ML_KEM_IMPLEMENTATIONS: Record = { + 512: ml_kem512, + 768: ml_kem768, + 1024: ml_kem1024, +}; + +let cachedZtdfSalt: Uint8Array | undefined; + +export function isMlKemAlgorithm(algorithm: string): algorithm is MlKemAlgorithm { + return algorithm === 'mlkem:512' || algorithm === 'mlkem:768' || algorithm === 'mlkem:1024'; +} + +export function mlKemAlgorithmFromLevel(level: MlKemLevel): MlKemAlgorithm { + if (!(level in ML_KEM_IMPLEMENTATIONS)) { + throw new ConfigurationError(`Unsupported ML-KEM level: ${level}`); + } + return `mlkem:${level}` as MlKemAlgorithm; +} + +export function mlKemLevelFromAlgorithm(algorithm: string): MlKemLevel | undefined { + if (!isMlKemAlgorithm(algorithm)) { + return undefined; + } + return Number.parseInt(algorithm.split(':')[1], 10) as MlKemLevel; +} + +export function mlKemPublicKeyAlgorithmFromLength(length: number): MlKemAlgorithm | undefined { + switch (length) { + case ML_KEM_PUBLIC_KEY_LENGTHS[512]: + return 'mlkem:512'; + case ML_KEM_PUBLIC_KEY_LENGTHS[768]: + return 'mlkem:768'; + case ML_KEM_PUBLIC_KEY_LENGTHS[1024]: + return 'mlkem:1024'; + default: + return undefined; + } +} + +export function mlKemPrivateKeyAlgorithmFromLength(length: number): MlKemAlgorithm | undefined { + switch (length) { + case ML_KEM_PRIVATE_KEY_LENGTHS[512]: + return 'mlkem:512'; + case ML_KEM_PRIVATE_KEY_LENGTHS[768]: + return 'mlkem:768'; + case ML_KEM_PRIVATE_KEY_LENGTHS[1024]: + return 'mlkem:1024'; + default: + return undefined; + } +} + +export function mlKemCiphertextLengthForAlgorithm(algorithm: KeyAlgorithm): number { + const level = mlKemLevelFromAlgorithm(algorithm); + if (!level) { + throw new ConfigurationError(`Unsupported ML-KEM algorithm: ${algorithm}`); + } + return ML_KEM_CIPHERTEXT_LENGTHS[level]; +} + +async function getZtdfSalt(): Promise { + if (cachedZtdfSalt) { + return cachedZtdfSalt; + } + cachedZtdfSalt = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode('TDF')) + ); + return cachedZtdfSalt; +} + +async function hkdfSharedSecret(sharedSecret: Uint8Array): Promise { + const hkdfKey = await crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveKey']); + const derivedKey = await crypto.subtle.deriveKey( + { + name: 'HKDF', + hash: 'SHA-256', + salt: await getZtdfSalt(), + info: new Uint8Array(0), + }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + const keyBytes = new Uint8Array(await crypto.subtle.exportKey('raw', derivedKey)); + return wrapSymmetricKey(keyBytes); +} + +function implementationForLevel(level: MlKemLevel): MlKemImplementation { + const implementation = ML_KEM_IMPLEMENTATIONS[level]; + if (!implementation) { + throw new ConfigurationError(`Unsupported ML-KEM level: ${level}`); + } + return implementation; +} + +export async function generateMlKemKeyPair(level: MlKemLevel, seed?: Uint8Array): Promise { + const algorithm = mlKemAlgorithmFromLevel(level); + const implementation = implementationForLevel(level); + const { publicKey, secretKey } = implementation.keygen(seed); + return { + publicKey: wrapMlKemPublicKey(publicKey, algorithm), + privateKey: wrapMlKemPrivateKey(secretKey, algorithm), + }; +} + +export async function mlKemEncapsulate( + publicKey: PublicKey +): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }> { + const level = mlKemLevelFromAlgorithm(publicKey.algorithm); + if (!level || publicKey.mlKemLevel !== level) { + throw new ConfigurationError(`ML-KEM public key metadata mismatch: ${publicKey.algorithm}`); + } + const implementation = implementationForLevel(level); + const { cipherText, sharedSecret } = implementation.encapsulate(unwrapMlKemPublicKey(publicKey)); + return { + ciphertext: new Uint8Array(cipherText), + sharedSecret: await hkdfSharedSecret(new Uint8Array(sharedSecret)), + }; +} + +export async function mlKemDecapsulate( + privateKey: PrivateKey, + ciphertext: Uint8Array +): Promise { + const level = mlKemLevelFromAlgorithm(privateKey.algorithm); + if (!level || privateKey.mlKemLevel !== level) { + throw new ConfigurationError(`ML-KEM private key metadata mismatch: ${privateKey.algorithm}`); + } + if (ciphertext.byteLength !== ML_KEM_CIPHERTEXT_LENGTHS[level]) { + throw new ConfigurationError( + `ML-KEM ciphertext length ${ciphertext.byteLength} does not match ${privateKey.algorithm}` + ); + } + const implementation = implementationForLevel(level); + const sharedSecret = implementation.decapsulate( + new Uint8Array(ciphertext), + unwrapMlKemPrivateKey(privateKey) + ); + return hkdfSharedSecret(new Uint8Array(sharedSecret)); +} diff --git a/lib/tdf3/src/crypto/declarations.ts b/lib/tdf3/src/crypto/declarations.ts index ba0c789dd..3aaef4789 100644 --- a/lib/tdf3/src/crypto/declarations.ts +++ b/lib/tdf3/src/crypto/declarations.ts @@ -21,6 +21,16 @@ export type PemKeyPair = { privateKey: string; }; +/** + * Key algorithm identifier combining key type and parameters. + */ +export type MlKemLevel = 512 | 768 | 1024; + +/** + * Supported ML-KEM algorithm identifiers. + */ +export type MlKemAlgorithm = 'mlkem:512' | 'mlkem:768' | 'mlkem:1024'; + /** * Key algorithm identifier combining key type and parameters. */ @@ -29,14 +39,15 @@ export type KeyAlgorithm = | 'rsa:4096' | 'ec:secp256r1' | 'ec:secp384r1' - | 'ec:secp521r1'; + | 'ec:secp521r1' + | MlKemAlgorithm; /** * Options for key generation and import. */ export type KeyOptions = { /** - * Key usage: 'encrypt' for RSA-OAEP, 'sign' for RSA/ECDSA signing, 'derive' for ECDH. + * Key usage: 'encrypt' for RSA-OAEP / ML-KEM, 'sign' for RSA/ECDSA signing, 'derive' for ECDH. * If not specified, defaults based on the generation method or key type. */ usage?: 'encrypt' | 'sign' | 'derive'; @@ -62,12 +73,14 @@ export type KeyOptions = { */ export type PublicKey = { readonly _brand: 'PublicKey'; - /** Algorithm identifier (e.g., 'rsa:2048', 'ec:secp256r1') */ + /** Algorithm identifier (e.g., 'rsa:2048', 'ec:secp256r1', 'mlkem:768') */ readonly algorithm: KeyAlgorithm; /** RSA modulus bit length (only for RSA keys) */ readonly modulusBits?: number; /** EC curve name (only for EC keys) */ readonly curve?: ECCurve; + /** ML-KEM security level (only for ML-KEM keys) */ + readonly mlKemLevel?: MlKemLevel; }; /** @@ -78,12 +91,14 @@ export type PublicKey = { */ export type PrivateKey = { readonly _brand: 'PrivateKey'; - /** Algorithm identifier (e.g., 'rsa:2048', 'ec:secp256r1') */ + /** Algorithm identifier (e.g., 'rsa:2048', 'ec:secp256r1', 'mlkem:768') */ readonly algorithm: KeyAlgorithm; /** RSA modulus bit length (only for RSA keys) */ readonly modulusBits?: number; /** EC curve name (only for EC keys) */ readonly curve?: ECCurve; + /** ML-KEM security level (only for ML-KEM keys) */ + readonly mlKemLevel?: MlKemLevel; }; /** @@ -156,8 +171,8 @@ export type HkdfParams = { */ export type PublicKeyInfo = { /** Detected algorithm of the key. */ - algorithm: 'rsa:2048' | 'rsa:4096' | 'ec:secp256r1' | 'ec:secp384r1' | 'ec:secp521r1'; - /** Normalized PEM string. */ + algorithm: KeyAlgorithm; + /** Normalized public key string: PEM for classical keys, raw base64 for ML-KEM. */ pem: string; }; @@ -273,6 +288,12 @@ export type CryptoService = { */ generateECKeyPair: (curve?: ECCurve) => Promise; + /** + * Generate an ML-KEM key pair for encapsulation/decapsulation. + * @param level - ML-KEM level to use + */ + generateMlKemKeyPair: (level: MlKemLevel) => Promise; + /** * Perform ECDH key agreement followed by HKDF key derivation. * Returns opaque symmetric key suitable for symmetric encryption. @@ -289,11 +310,25 @@ export type CryptoService = { hkdfParams: HkdfParams ) => Promise; + /** + * Encapsulate a shared secret to an ML-KEM public key. + * Implementations return the derived AES-256 key material used for wrapping. + */ + mlKemEncapsulate: ( + publicKey: PublicKey + ) => Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }>; + + /** + * Decapsulate an ML-KEM ciphertext with an ML-KEM private key. + * Implementations return the derived AES-256 key material used for wrapping. + */ + mlKemDecapsulate: (privateKey: PrivateKey, ciphertext: Uint8Array) => Promise; + // === Key Import (PEM → opaque) === /** - * Import a PEM public key as an opaque key. - * @param pem - PEM-encoded public key + * Import a public key as an opaque key. + * @param pem - PEM-encoded public key, certificate, or raw base64 ML-KEM public key * @param options - Import options (usage required for RSA keys to disambiguate encrypt vs sign) * @returns Opaque public key with metadata */ @@ -303,17 +338,17 @@ export type CryptoService = { * Import a PEM private key as an opaque key. * Optional - intended for use in tests or by downstream integrators who need to bring * their own PEM key material. Main SDK code should use opaque PrivateKey objects directly. - * @param pem - PEM-encoded private key + * @param pem - PEM-encoded private key or raw base64 ML-KEM private key * @param options - Import options (usage required for RSA keys to disambiguate encrypt vs sign) * @returns Opaque private key with metadata */ importPrivateKey?: (pem: string, options: KeyOptions) => Promise; /** - * Parse and validate a PEM public key, returning algorithm info. + * Parse and validate a public key, returning algorithm info. * - * @param pem - PEM-encoded public key or X.509 certificate - * @returns Validated PEM and detected algorithm + * @param pem - PEM-encoded public key, X.509 certificate, or raw base64 ML-KEM public key + * @returns Validated public key string and detected algorithm * @throws ConfigurationError if key format invalid or algorithm not supported */ parsePublicKeyPem: (pem: string) => Promise; @@ -323,7 +358,7 @@ export type CryptoService = { /** * Export an opaque public key to PEM format. * @param key - Opaque public key - * @returns PEM-encoded public key (SPKI format) + * @returns PEM-encoded public key (SPKI format) or raw base64 for ML-KEM */ exportPublicKeyPem: (key: PublicKey) => Promise; @@ -331,7 +366,7 @@ export type CryptoService = { * OPTIONAL -- ONLY USE FOR TESTING/DEVELOPMENT. Private keys should NOT be exportable in secure environments. * Export an opaque private key to PEM format. * @param key - Opaque private key - * @returns PEM-encoded private key (PKCS8 format) + * @returns PEM-encoded private key (PKCS8 format) or raw base64 for ML-KEM */ exportPrivateKeyPem?: (key: PrivateKey) => Promise; /** diff --git a/lib/tdf3/src/crypto/index.ts b/lib/tdf3/src/crypto/index.ts index cb24fa03e..735a1857e 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, @@ -85,6 +87,8 @@ export { importPublicKey, importSymmetricKey, jwkToPublicKeyPem, + mlKemDecapsulate, + mlKemEncapsulate, mergeSymmetricKeys, parsePublicKeyPem, publicKeyPemToJwk, @@ -113,11 +117,14 @@ export const DefaultCryptoService: CryptoService = { generateECKeyPair, generateKey, generateKeyPair, + generateMlKemKeyPair, generateSigningKeyPair, importPrivateKey, importPublicKey, importSymmetricKey, jwkToPublicKeyPem, + mlKemDecapsulate, + mlKemEncapsulate, mergeSymmetricKeys, parsePublicKeyPem, randomBytes, diff --git a/lib/tdf3/src/models/key-access.ts b/lib/tdf3/src/models/key-access.ts index 6be30317a..d3815bc75 100644 --- a/lib/tdf3/src/models/key-access.ts +++ b/lib/tdf3/src/models/key-access.ts @@ -1,6 +1,11 @@ import { base64, hex } from '../../../src/encodings/index.js'; import { Binary } from '../binary.js'; -import type { CryptoService, KeyPair, SymmetricKey } from '../crypto/declarations.js'; +import type { + CryptoService, + KeyPair, + MlKemAlgorithm, + SymmetricKey, +} from '../crypto/declarations.js'; import { getZtdfSalt } from '../crypto/salt.js'; import { Algorithms } from '../ciphers/index.js'; import { Policy } from './policy.js'; @@ -152,7 +157,81 @@ export class Wrapped { } } -export type KeyAccess = ECWrapped | Wrapped; +export class MlKemWrapped { + readonly type = 'wrapped'; + keyAccessObject?: KeyAccessObject; + + constructor( + public readonly url: string, + public readonly kid: string | undefined, + public readonly publicKey: string, + public readonly algorithm: MlKemAlgorithm, + 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); + const kasPublicKey = await this.cryptoService.importPublicKey(this.publicKey, { + usage: 'encrypt', + algorithmHint: this.algorithm, + }); + const { ciphertext: mlKemCiphertext, sharedSecret } = + await this.cryptoService.mlKemEncapsulate(kasPublicKey); + + const iv = await this.cryptoService.randomBytes(12); + const encryptResult = await this.cryptoService.encrypt( + dek, + sharedSecret, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + + const ciphertext = new Uint8Array(encryptResult.payload.asArrayBuffer()); + const authTag = encryptResult.authTag + ? new Uint8Array(encryptResult.authTag.asArrayBuffer()) + : new Uint8Array(0); + const entityWrappedKey = new Uint8Array( + mlKemCiphertext.length + iv.length + ciphertext.length + authTag.length + ); + entityWrappedKey.set(mlKemCiphertext); + entityWrappedKey.set(iv, mlKemCiphertext.length); + entityWrappedKey.set(ciphertext, mlKemCiphertext.length + iv.length); + entityWrappedKey.set(authTag, mlKemCiphertext.length + iv.length + ciphertext.length); + + 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(entityWrappedKey), + 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 | Wrapped | MlKemWrapped; /** * 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..a31372ad4 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -41,6 +41,7 @@ import { type CryptoService, type DecryptResult, type KeyPair, + type MlKemAlgorithm, type SymmetricKey, } from './crypto/declarations.js'; import { Algorithms } from './ciphers/index.js'; @@ -49,6 +50,7 @@ import { KeyAccessType, KeyInfo, Manifest, + MlKemWrapped, Policy, SplitKey, Wrapped, @@ -59,6 +61,7 @@ import { import { unsigned } from './utils/buffer-crc32.js'; import { ZipReader, ZipWriter, concatUint8, buffToString } from './utils/index.js'; import { CentralDirectory } from './utils/zip-reader.js'; +import { mlKemCiphertextLengthForAlgorithm } from './crypto/core/mlkem.js'; import { getZtdfSalt } from './crypto/salt.js'; import { Payload } from './models/payload.js'; import { @@ -231,6 +234,9 @@ export async function extractPemFromKeyString( alg: KasPublicKeyAlgorithm, cryptoService: CryptoService ): Promise { + if (alg.startsWith('mlkem:')) { + return keyString.replace(/[\r\n\s]/g, ''); + } // Convert KAS algorithm to JWA algorithm if provided const jwaAlgorithm = publicKeyAlgorithmToJwa(alg); // extractPublicKeyPem handles both X.509 certificates and raw PEM keys @@ -280,6 +286,17 @@ export async function buildKeyAccess({ } switch (type) { case 'wrapped': + if (alg.startsWith('mlkem:')) { + return new MlKemWrapped( + url, + kid, + pubKey, + alg as MlKemAlgorithm, + metadata, + cryptoService, + sid + ); + } return new Wrapped(url, kid, pubKey, metadata, cryptoService, sid); case 'ec-wrapped': return new ECWrapped(url, kid, pubKey, metadata, cryptoService, sid); @@ -776,6 +793,14 @@ async function unwrapKey({ if (wrappingKeyAlgorithm === 'ec:secp256r1') { // Generate EC key pair via CryptoService (returns opaque keys) ephemeralEncryptionKeys = await cryptoService.generateECKeyPair('P-256'); + } else if ( + wrappingKeyAlgorithm === 'mlkem:512' || + wrappingKeyAlgorithm === 'mlkem:768' || + wrappingKeyAlgorithm === 'mlkem:1024' + ) { + ephemeralEncryptionKeys = await cryptoService.generateMlKemKeyPair( + Number.parseInt(wrappingKeyAlgorithm.split(':')[1], 10) as 512 | 768 | 1024 + ); } else if (wrappingKeyAlgorithm === 'rsa:2048' || !wrappingKeyAlgorithm) { // generateKeyPair() returns opaque keys ephemeralEncryptionKeys = await cryptoService.generateKeyPair(); @@ -892,6 +917,33 @@ async function unwrapKey({ requiredObligations, }; } + if ( + wrappingKeyAlgorithm === 'mlkem:512' || + wrappingKeyAlgorithm === 'mlkem:768' || + wrappingKeyAlgorithm === 'mlkem:1024' + ) { + const mlKemCiphertextLength = mlKemCiphertextLengthForAlgorithm(wrappingKeyAlgorithm); + const mlKemCiphertext = entityWrappedKey.slice(0, mlKemCiphertextLength); + const wrappedKeyAndNonce = entityWrappedKey.slice(mlKemCiphertextLength); + const iv = wrappedKeyAndNonce.slice(0, 12); + const wrappedKey = wrappedKeyAndNonce.slice(12); + const derivedKey = await cryptoService.mlKemDecapsulate( + ephemeralEncryptionKeys.privateKey, + mlKemCiphertext + ); + const decryptResult = await cryptoService.decrypt( + Binary.fromArrayBuffer(wrappedKey.buffer), + derivedKey, + 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, diff --git a/lib/tests/mocha/encrypt-decrypt.spec.ts b/lib/tests/mocha/encrypt-decrypt.spec.ts index e1ba9fc72..107a43c8a 100644 --- a/lib/tests/mocha/encrypt-decrypt.spec.ts +++ b/lib/tests/mocha/encrypt-decrypt.spec.ts @@ -244,9 +244,16 @@ describe('rewrap error cases', function () { 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[]) { + const wrappingAlgorithms: KasPublicKeyAlgorithm[] = [ + 'ec:secp256r1', + 'rsa:2048', + 'mlkem:512', + 'mlkem:768', + 'mlkem:1024', + ]; + + for (const encapKeyType of wrappingAlgorithms) { + for (const rewrapKeyType of wrappingAlgorithms) { it(`encrypt-decrypt stream source happy path {encap: ${encapKeyType}, rewrap: ${rewrapKeyType}}`, async function () { const cipher = new AesGcmCipher(WebCryptoService); const encryptionInformation = new SplitKey(cipher); diff --git a/lib/tests/mocha/unit/access.spec.ts b/lib/tests/mocha/unit/access.spec.ts new file mode 100644 index 000000000..e0189bfb3 --- /dev/null +++ b/lib/tests/mocha/unit/access.spec.ts @@ -0,0 +1,36 @@ +import { expect } from 'chai'; + +import { isPublicKeyAlgorithm, type KasPublicKeyAlgorithm } from '../../../src/access.js'; + +describe('access', () => { + it('accepts exactly the supported public key algorithms', () => { + const supported: KasPublicKeyAlgorithm[] = [ + 'ec:secp256r1', + 'ec:secp384r1', + 'ec:secp521r1', + 'rsa:2048', + 'rsa:4096', + 'mlkem:512', + 'mlkem:768', + 'mlkem:1024', + ]; + + for (const algorithm of supported) { + expect(isPublicKeyAlgorithm(algorithm), algorithm).to.equal(true); + } + + for (const algorithm of [ + '', + 'rsa:1024', + 'rsa:8192', + 'ec:secp256k1', + 'mlkem:256', + 'mlkem:1536', + 'mlkem768', + 'MLKEM:768', + 'foo', + ]) { + expect(isPublicKeyAlgorithm(algorithm), algorithm).to.equal(false); + } + }); +}); diff --git a/lib/tests/mocha/unit/crypto-di.spec.ts b/lib/tests/mocha/unit/crypto-di.spec.ts index 3911d8884..f67f2aa9c 100644 --- a/lib/tests/mocha/unit/crypto-di.spec.ts +++ b/lib/tests/mocha/unit/crypto-di.spec.ts @@ -98,6 +98,9 @@ describe('CryptoService DI', () => { generateECKeyPair: function (curve?: ECCurve): Promise { throw new Error(NOT_IMPLEMENTED); }, + generateMlKemKeyPair: function (): Promise { + throw new Error(NOT_IMPLEMENTED); + }, deriveKeyFromECDH: function ( privateKey: PrivateKey, publicKey: PublicKey, @@ -105,6 +108,17 @@ describe('CryptoService DI', () => { ): Promise { throw new Error(NOT_IMPLEMENTED); }, + mlKemEncapsulate: function ( + publicKey: PublicKey + ): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }> { + throw new Error(NOT_IMPLEMENTED); + }, + mlKemDecapsulate: function ( + privateKey: PrivateKey, + ciphertext: Uint8Array + ): Promise { + throw new Error(NOT_IMPLEMENTED); + }, importPublicKey: function (pem: string): Promise { throw new Error(NOT_IMPLEMENTED); }, @@ -267,6 +281,9 @@ describe('CryptoService DI', () => { generateECKeyPair: function (curve?: ECCurve): Promise { throw new Error(NOT_IMPLEMENTED); }, + generateMlKemKeyPair: function (): Promise { + throw new Error(NOT_IMPLEMENTED); + }, deriveKeyFromECDH: function ( privateKey: PrivateKey, publicKey: PublicKey, @@ -274,6 +291,17 @@ describe('CryptoService DI', () => { ): Promise { throw new Error(NOT_IMPLEMENTED); }, + mlKemEncapsulate: function ( + publicKey: PublicKey + ): Promise<{ ciphertext: Uint8Array; sharedSecret: SymmetricKey }> { + throw new Error(NOT_IMPLEMENTED); + }, + mlKemDecapsulate: function ( + privateKey: PrivateKey, + ciphertext: Uint8Array + ): Promise { + throw new Error(NOT_IMPLEMENTED); + }, importPublicKey: function (pem: string): Promise { throw new Error(NOT_IMPLEMENTED); }, diff --git a/lib/tests/mocha/unit/crypto/crypto-service.spec.ts b/lib/tests/mocha/unit/crypto/crypto-service.spec.ts index 7acd43bfd..1f6692f24 100644 --- a/lib/tests/mocha/unit/crypto/crypto-service.spec.ts +++ b/lib/tests/mocha/unit/crypto/crypto-service.spec.ts @@ -13,9 +13,12 @@ import { generateECKeyPair, generateKey, generateKeyPair, + generateMlKemKeyPair, generateSigningKeyPair, hex2Ab, importPublicKey, + mlKemDecapsulate, + mlKemEncapsulate, parsePublicKeyPem, importSymmetricKey, jwkToPublicKeyPem, @@ -316,6 +319,34 @@ describe('Crypto Service', () => { }); }); + describe('generateMlKemKeyPair', () => { + for (const [level, algorithm] of [ + [512, 'mlkem:512'], + [768, 'mlkem:768'], + [1024, 'mlkem:1024'], + ] as const) { + it(`should generate ${algorithm} key pair`, async () => { + const keyPair = await generateMlKemKeyPair(level); + const exported = await exportPublicKeyPem(keyPair.publicKey); + const parsed = await parsePublicKeyPem(exported); + const imported = await importPublicKey(exported, { + usage: 'encrypt', + algorithmHint: algorithm, + }); + + expect(keyPair.publicKey).to.have.property('_brand', 'PublicKey'); + expect(keyPair.publicKey).to.have.property('algorithm', algorithm); + expect(keyPair.publicKey).to.have.property('mlKemLevel', level); + expect(keyPair.privateKey).to.have.property('_brand', 'PrivateKey'); + expect(keyPair.privateKey).to.have.property('algorithm', algorithm); + expect(keyPair.privateKey).to.have.property('mlKemLevel', level); + expect(exported).to.not.include('BEGIN PUBLIC KEY'); + expect(parsed.algorithm).to.equal(algorithm); + expect(imported.algorithm).to.equal(algorithm); + }); + } + }); + describe('deriveKeyFromECDH', () => { it('should derive key from ECDH with P-256', async () => { const aliceKeys = await generateECKeyPair('P-256'); @@ -365,6 +396,25 @@ describe('Crypto Service', () => { }); }); + describe('mlKemEncapsulate / mlKemDecapsulate', () => { + for (const [level, algorithm] of [ + [512, 'mlkem:512'], + [768, 'mlkem:768'], + [1024, 'mlkem:1024'], + ] as const) { + it(`should round-trip ${algorithm} shared secret`, async () => { + const keyPair = await generateMlKemKeyPair(level); + const { ciphertext, sharedSecret } = await mlKemEncapsulate(keyPair.publicKey); + const decapsulated = await mlKemDecapsulate(keyPair.privateKey, ciphertext); + + expect(ciphertext).to.be.instanceOf(Uint8Array); + expect(sharedSecret).to.have.property('_brand', 'SymmetricKey'); + expect(sharedSecret).to.have.property('length', 256); + expect(decapsulated).to.deep.equal(sharedSecret); + }); + } + }); + describe('hmac and verifyHmac', () => { it('should sign and verify with HMAC-SHA256', async () => { const data = new TextEncoder().encode('test data'); @@ -439,6 +489,15 @@ describe('Crypto Service', () => { expect(result.algorithm).to.equal('ec:secp521r1'); expect(result.pem).to.equal(publicKeyPem); }); + + it('should import raw ML-KEM public key', async () => { + const mlKemKeyPair = await generateMlKemKeyPair(768); + const rawPublicKey = await exportPublicKeyPem(mlKemKeyPair.publicKey); + + const result = await parsePublicKeyPem(rawPublicKey); + expect(result.algorithm).to.equal('mlkem:768'); + expect(result.pem).to.equal(rawPublicKey); + }); }); describe('sign and verify with ES256', () => { diff --git a/lib/tests/mocha/unit/key-access.test.ts b/lib/tests/mocha/unit/key-access.test.ts index 274de3a4d..1ffa7aa8d 100644 --- a/lib/tests/mocha/unit/key-access.test.ts +++ b/lib/tests/mocha/unit/key-access.test.ts @@ -1,6 +1,6 @@ import { expect } from 'chai'; -import { ECWrapped, Wrapped } from '../../../tdf3/src/models/key-access.js'; +import { ECWrapped, MlKemWrapped, Wrapped } from '../../../tdf3/src/models/key-access.js'; import { Policy } from '../../../tdf3/src/models/policy.js'; import { base64 } from '../../../src/encodings/index.js'; import type { CryptoService, KeyPair } from '../../../tdf3/src/crypto/declarations.js'; @@ -19,21 +19,42 @@ const mockCryptoService: CryptoService = { const keyBytes = new Uint8Array(32); return await importSymmetricKey(keyBytes); }, + async generateMlKemKeyPair(): Promise { + return { + publicKey: { _brand: 'PublicKey', algorithm: 'mlkem:768', mlKemLevel: 768 } as any, + privateKey: { _brand: 'PrivateKey', algorithm: 'mlkem:768', mlKemLevel: 768 } as any, + }; + }, + async mlKemEncapsulate() { + return { + ciphertext: new Uint8Array([1, 2, 3, 4]), + sharedSecret: await importSymmetricKey(new Uint8Array(32)), + }; + }, + async mlKemDecapsulate() { + return await importSymmetricKey(new Uint8Array(32)); + }, async randomBytes(length: number): Promise { return new Uint8Array(length); }, + async digest() { + return new Uint8Array(32); + }, async encrypt() { return { payload: Binary.fromArrayBuffer(new Uint8Array(16).buffer), authTag: Binary.fromArrayBuffer(new Uint8Array(16).buffer), }; }, - async hmac(): Promise { - return 'mock-hmac-hash'; + async hmac(): Promise { + return new Uint8Array(32); }, async encryptWithPublicKey() { return Binary.fromString('mock-wrapped-key'); }, + async importPublicKey() { + return { _brand: 'PublicKey', algorithm: 'rsa:2048' } as any; + }, async exportPublicKeyPem() { return 'ephemeral-public-key-pem'; }, @@ -103,6 +124,27 @@ describe('ECWrapped', () => { expect(wrapped.type).to.equal('wrapped'); }); + it('should write and preserve the ML-KEM wrapped key layout', async () => { + const wrapped = new MlKemWrapped( + url, + kid, + base64.encodeArrayBuffer(new Uint8Array(1184).buffer), + 'mlkem:768', + metadata, + mockCryptoService, + sid + ); + + const dek = await importSymmetricKey(dekBytes); + const keyAccessObject = await wrapped.write(policy, dek, encryptedMetadataStr); + const wrappedKey = new Uint8Array(base64.decodeArrayBuffer(keyAccessObject.wrappedKey || '')); + + expect(keyAccessObject.type).to.equal('wrapped'); + expect(wrappedKey.byteLength).to.equal(4 + 12 + 16 + 16); + expect(Array.from(wrappedKey.slice(0, 4))).to.deep.equal([1, 2, 3, 4]); + expect(Array.from(wrappedKey.slice(4))).to.deep.equal(Array.from(new Uint8Array(44))); + }); + it(`should handle undefined kid for ECWrapped`, () => { const wrappedInstance = new ECWrapped(url, undefined, publicKey, metadata, mockCryptoService); expect(wrappedInstance.kid).to.be.undefined; diff --git a/lib/tests/mocha/unit/tdf.spec.ts b/lib/tests/mocha/unit/tdf.spec.ts index 2e476ac3b..b5c3039c5 100644 --- a/lib/tests/mocha/unit/tdf.spec.ts +++ b/lib/tests/mocha/unit/tdf.spec.ts @@ -4,6 +4,7 @@ import * as TDF from '../../../tdf3/src/tdf.js'; import { KeyAccessObject } from '../../../tdf3/src/models/key-access.js'; import { PolicyBody, type Policy } from '../../../tdf3/src/models/policy.js'; import { OriginAllowList } from '../../../src/access.js'; +import { base64 } from '../../../src/encodings/index.js'; import { ConfigurationError, InvalidFileError, UnsafeUrlError } from '../../../src/errors.js'; import { getMocks } from '../../mocks/index.js'; import * as DefaultCryptoService from '../../../tdf3/src/crypto/index.js'; @@ -69,6 +70,12 @@ describe('TDF', () => { expect(pem).to.include('-----BEGIN PUBLIC KEY-----'); expect(pem).to.include('-----END PUBLIC KEY-----'); }); + + it('should preserve raw ML-KEM public key encoding', async () => { + const rawPublicKey = base64.encodeArrayBuffer(new Uint8Array(1184).buffer); + const key = await TDF.extractPemFromKeyString(rawPublicKey, 'mlkem:768', cryptoService); + expect(key).to.equal(rawPublicKey); + }); }); describe('fetchKasPublicKey', async () => { diff --git a/lib/tests/server.ts b/lib/tests/server.ts index 6f389b201..3dbd197ab 100644 --- a/lib/tests/server.ts +++ b/lib/tests/server.ts @@ -1,17 +1,15 @@ import * as jose from 'jose'; import { createServer, IncomingMessage, RequestListener } from 'node:http'; -import { base64 } from '../src/encodings/index.js'; +import { Algorithms, Binary } from '../tdf3/index.js'; import { decryptWithPrivateKey, encryptWithPublicKey } from '../tdf3/src/crypto/index.js'; import { getMocks } from './mocks/index.js'; -import { keyAgreement, pemPublicToCrypto } from '../src/crypto/index.js'; -import { generateRandomNumber } from '../src/crypto/generateRandomNumber.js'; -import { removePemFormatting } from '../tdf3/src/crypto/crypto-utils.js'; -import { 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 type { MlKemAlgorithm } from '../tdf3/src/crypto/declarations.js'; +import { mlKemCiphertextLengthForAlgorithm } from '../tdf3/src/crypto/core/mlkem.js'; import { create, toJsonString, fromJson } from '@bufbuild/protobuf'; import { ValueSchema } from '@bufbuild/protobuf/wkt'; @@ -24,6 +22,79 @@ import { const Mocks = getMocks(); +const ML_KEM_KIDS: Record = { + 'mlkem:512': 'm512', + 'mlkem:768': 'm768', + 'mlkem:1024': 'm1024', +}; + +const ML_KEM_ALGORITHMS_BY_KID: Record = Object.fromEntries( + Object.entries(ML_KEM_KIDS).map(([algorithm, kid]) => [kid, algorithm as MlKemAlgorithm]) +) as Record; + +const mlKemKasKeyPairs = { + 'mlkem:512': DefaultCryptoService.generateMlKemKeyPair(512), + 'mlkem:768': DefaultCryptoService.generateMlKemKeyPair(768), + 'mlkem:1024': DefaultCryptoService.generateMlKemKeyPair(1024), +} as const; + +async function mlKemKasPublicKey(algorithm: MlKemAlgorithm): Promise { + const keyPair = await mlKemKasKeyPairs[algorithm]; + return DefaultCryptoService.exportPublicKeyPem(keyPair.publicKey); +} + +async function unwrapMlKemWrappedKey( + wrappedKey: Uint8Array, + algorithm: MlKemAlgorithm +): Promise { + const keyPair = await mlKemKasKeyPairs[algorithm]; + const mlKemCiphertextLength = mlKemCiphertextLengthForAlgorithm(algorithm); + const mlKemCiphertext = wrappedKey.slice(0, mlKemCiphertextLength); + const wrappedKeyAndNonce = wrappedKey.slice(mlKemCiphertextLength); + const iv = wrappedKeyAndNonce.slice(0, 12); + const encryptedDek = wrappedKeyAndNonce.slice(12); + const kek = await DefaultCryptoService.mlKemDecapsulate(keyPair.privateKey, mlKemCiphertext); + const decryptResult = await DefaultCryptoService.decrypt( + Binary.fromArrayBuffer(encryptedDek.buffer), + kek, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + return decryptResult.payload; +} + +async function wrapDekForMlKemClient( + dek: Binary, + clientPublicKeyRaw: string, + algorithm: MlKemAlgorithm +): Promise { + const clientPublicKey = await DefaultCryptoService.importPublicKey(clientPublicKeyRaw, { + usage: 'encrypt', + algorithmHint: algorithm, + }); + const { ciphertext: mlKemCiphertext, sharedSecret } = + await DefaultCryptoService.mlKemEncapsulate(clientPublicKey); + const iv = await DefaultCryptoService.randomBytes(12); + const encryptResult = await DefaultCryptoService.encrypt( + dek, + sharedSecret, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + const ciphertext = new Uint8Array(encryptResult.payload.asArrayBuffer()); + const authTag = encryptResult.authTag + ? new Uint8Array(encryptResult.authTag.asArrayBuffer()) + : new Uint8Array(0); + const entityWrappedKey = new Uint8Array( + mlKemCiphertext.length + iv.length + ciphertext.length + authTag.length + ); + entityWrappedKey.set(mlKemCiphertext); + entityWrappedKey.set(iv, mlKemCiphertext.length); + entityWrappedKey.set(ciphertext, mlKemCiphertext.length + iv.length); + entityWrappedKey.set(authTag, mlKemCiphertext.length + iv.length + ciphertext.length); + return entityWrappedKey; +} + function range(start: number, end: number): Uint8Array { const result = []; for (let i = start; i <= end; i++) { @@ -95,7 +166,9 @@ const kas: RequestListener = async (req, res) => { const params = JSON.parse(bodyText); const algorithm = params.algorithm || 'rsa:2048'; - if (!['ec:secp256r1', 'rsa:2048'].includes(algorithm)) { + if ( + !['ec:secp256r1', 'rsa:2048', 'mlkem:512', 'mlkem:768', 'mlkem:1024'].includes(algorithm) + ) { console.log(`[DEBUG] invalid algorithm [${algorithm}]`); res.writeHead(400); res.end(`{"error": "Invalid algorithm [${algorithm}]"}`); @@ -110,8 +183,18 @@ const kas: RequestListener = async (req, res) => { } res.setHeader('Content-Type', 'application/json'); res.statusCode = 200; - const publicKey = 'ec:secp256r1' == algorithm ? Mocks.kasECCert : Mocks.kasPublicKey; - const kid = 'ec:secp256r1' == algorithm ? 'e1' : 'r1'; + const publicKey = + algorithm === 'ec:secp256r1' + ? Mocks.kasECCert + : algorithm === 'rsa:2048' + ? Mocks.kasPublicKey + : await mlKemKasPublicKey(algorithm as MlKemAlgorithm); + const kid = + algorithm === 'ec:secp256r1' + ? 'e1' + : algorithm === 'rsa:2048' + ? 'r1' + : ML_KEM_KIDS[algorithm as MlKemAlgorithm]; res.end(JSON.stringify({ kid, publicKey })); return; } else if (url.pathname === '/kas.AccessService/Rewrap') { @@ -209,12 +292,9 @@ 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; - } + const clientPublicKeyInfo = await DefaultCryptoService.parsePublicKeyPem( + rewrap.clientPublicKey + ); const keyAccessObject = rewrap.requests?.[0]?.keyAccessObjects?.[0]?.keyAccessObject; const kaoheader = keyAccessObject?.header; const isZTDF = !kaoheader || kaoheader.length === 0; @@ -225,40 +305,50 @@ const kas: RequestListener = async (req, res) => { res.end('{"error": "Invalid wrapped key"}'); return; } - const isECWrapped = keyAccessObject?.kid == 'e1'; // Decrypt the wrapped key from TDF3 let dek: Binary; - if (isECWrapped) { + if (keyAccessObject?.keyType === 'ec-wrapped') { if (!keyAccessObject?.ephemeralPublicKey) { res.writeHead(400); res.end('{"error": "Nil ephemeral public key"}'); return; } - const ephemeralKey: CryptoKey = await pemPublicToCrypto( - keyAccessObject?.ephemeralPublicKey + const ephemeralKey = await DefaultCryptoService.importPublicKey( + keyAccessObject.ephemeralPublicKey, + { usage: 'derive' } ); - const kasPrivateKeyBytes = base64.decodeArrayBuffer( - removePemFormatting(Mocks.kasECPrivateKey) - ); - const kasPrivateKey = await crypto.subtle.importKey( - 'pkcs8', - kasPrivateKeyBytes, - { name: 'ECDH', namedCurve: 'P-256' }, - false, - ['deriveBits', 'deriveKey'] + const kasPrivateKey = await DefaultCryptoService.importPrivateKey!( + Mocks.kasECPrivateKey, + { + usage: 'derive', + algorithmHint: 'ec:secp256r1', + } ); - const kek = await keyAgreement(kasPrivateKey, ephemeralKey, { - hkdfSalt: await getZtdfSalt(DefaultCryptoService), - hkdfHash: 'SHA-256', + const kek = await DefaultCryptoService.deriveKeyFromECDH(kasPrivateKey, ephemeralKey, { + hash: 'SHA-256', + salt: await getZtdfSalt(DefaultCryptoService), }); const iv = wk.slice(0, 12); const wrappedKey = wk.slice(12); - const dekab = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, wrappedKey); - dek = Binary.fromArrayBuffer(dekab); + const decryptResult = await DefaultCryptoService.decrypt( + Binary.fromArrayBuffer(wrappedKey.buffer), + kek, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + dek = decryptResult.payload; + } else if (keyAccessObject?.kid && keyAccessObject.kid in ML_KEM_ALGORITHMS_BY_KID) { + dek = await unwrapMlKemWrappedKey( + wk, + ML_KEM_ALGORITHMS_BY_KID[keyAccessObject.kid] as MlKemAlgorithm + ); } else { - dek = await decryptWithPrivateKey(Binary.fromArrayBuffer(wk), Mocks.kasPrivateKey); + const kasPrivateKey = await DefaultCryptoService.importPrivateKey!(Mocks.kasPrivateKey, { + usage: 'encrypt', + }); + dek = await decryptWithPrivateKey(Binary.fromArrayBuffer(wk), kasPrivateKey); } - if (clientPublicKey.algorithm.name == 'RSA-OAEP') { + if (clientPublicKeyInfo.algorithm.startsWith('rsa:')) { // Import the client public key as opaque PublicKey for encryptWithPublicKey const clientPubKeyOpaque = await DefaultCryptoService.importPublicKey( rewrap.clientPublicKey, @@ -291,24 +381,67 @@ const kas: RequestListener = async (req, res) => { res.end(toJsonString(RewrapResponseSchema, reply)); return; } - const sessionKeyPair = await crypto.subtle.generateKey( + if (clientPublicKeyInfo.algorithm.startsWith('mlkem:')) { + const entityWrappedKey = await wrapDekForMlKemClient( + dek, + rewrap.clientPublicKey, + clientPublicKeyInfo.algorithm as MlKemAlgorithm + ); + 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; + } + const clientPublicKey = await DefaultCryptoService.importPublicKey(rewrap.clientPublicKey, { + usage: 'derive', + }); + const sessionKeyPair = await DefaultCryptoService.generateECKeyPair('P-256'); + const kek = await DefaultCryptoService.deriveKeyFromECDH( + sessionKeyPair.privateKey, + clientPublicKey, { - name: 'ECDH', - namedCurve: 'P-256', - }, - false, - ['deriveBits', 'deriveKey'] + hash: 'SHA-256', + salt: await getZtdfSalt(DefaultCryptoService), + } ); - const kek = await keyAgreement(sessionKeyPair.privateKey, clientPublicKey, { - hkdfSalt: await getZtdfSalt(DefaultCryptoService), - hkdfHash: 'SHA-256', - }); - const iv = generateRandomNumber(12); - const cek = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, dek.asArrayBuffer()); - const entityWrappedKey = new Uint8Array(iv.length + cek.byteLength); + const iv = await DefaultCryptoService.randomBytes(12); + const encryptResult = await DefaultCryptoService.encrypt( + dek, + kek, + Binary.fromArrayBuffer(iv.buffer), + Algorithms.AES_256_GCM + ); + const ciphertext = new Uint8Array(encryptResult.payload.asArrayBuffer()); + const authTag = encryptResult.authTag + ? new Uint8Array(encryptResult.authTag.asArrayBuffer()) + : new Uint8Array(0); + const entityWrappedKey = new Uint8Array(iv.length + ciphertext.length + authTag.length); entityWrappedKey.set(iv); - entityWrappedKey.set(new Uint8Array(cek), iv.length); + entityWrappedKey.set(ciphertext, iv.length); + entityWrappedKey.set(authTag, iv.length + ciphertext.length); const reply = create(RewrapResponseSchema, { + sessionPublicKey: await DefaultCryptoService.exportPublicKeyPem(sessionKeyPair.publicKey), responses: [ create(PolicyRewrapResultSchema, { results: [ diff --git a/lib/tests/web/roundtrip.test.ts b/lib/tests/web/roundtrip.test.ts index 3ab402a7a..b82487eee 100644 --- a/lib/tests/web/roundtrip.test.ts +++ b/lib/tests/web/roundtrip.test.ts @@ -146,6 +146,42 @@ describe('Local roundtrip Tests', () => { expect(decoded).to.be.equal('hello world'); }); + for (const algorithm of ['mlkem:768', 'mlkem:1024'] as const) { + it(`ztdf roundtrip string with ${algorithm}`, async () => { + const client = new OpenTDF({ + authProvider, + defaultReadOptions: { + allowedKASEndpoints: [kasEndpoint], + wrappingKeyAlgorithm: algorithm, + }, + }); + const cipherTextStream = await client.createZTDF({ + autoconfigure: false, + defaultKASEndpoint: kasEndpoint, + wrappingKeyAlgorithm: algorithm, + source: { type: 'chunker', location: fromString('hello world') }, + }); + const cipherManifest = await cipherTextStream.manifest; + const kao = cipherManifest?.encryptionInformation?.keyAccess[0]; + expect(kao).to.contain({ + url: kasEndpoint, + kid: algorithm === 'mlkem:768' ? 'm768' : 'm1024', + type: 'wrapped', + protocol: 'kas', + schemaVersion: '1.0', + }); + const cipherTextArray = new Uint8Array(await new Response(cipherTextStream).arrayBuffer()); + + const ztdfParsed = await client.read({ + source: { type: 'buffer', location: cipherTextArray }, + wrappingKeyAlgorithm: algorithm, + }); + + const actual = await new Response(ztdfParsed).arrayBuffer(); + expect(new TextDecoder().decode(actual)).to.be.equal('hello world'); + }); + } + it(`ztdf roundtrip chunker decrypt with bounded segment scheduler`, async () => { const client = new OpenTDF({ authProvider, diff --git a/web-app/src/App.tsx b/web-app/src/App.tsx index 9d315e7b3..e9e996b7c 100644 --- a/web-app/src/App.tsx +++ b/web-app/src/App.tsx @@ -408,6 +408,9 @@ function App() { cipherText = await client.createZTDF({ autoconfigure: false, source: { type: 'stream', location: source.pipeThrough(progressTransformers.reader) }, + ...(config.wrappingKeyAlgorithm && { + wrappingKeyAlgorithm: config.wrappingKeyAlgorithm, + }), }); } catch (e) { setDownloadState(`Encrypt Failed: ${e}`); @@ -462,6 +465,9 @@ function App() { authProvider: oidcClient, defaultReadOptions: { allowedKASEndpoints: [config.kas], + ...(config.rewrapWrappingKeyAlgorithm && { + wrappingKeyAlgorithm: config.rewrapWrappingKeyAlgorithm, + }), ...decryptReadTuning, }, dpopKeys: oidcClient.getSigningKey(), diff --git a/web-app/src/config.ts b/web-app/src/config.ts index f31accf7a..e51011eea 100644 --- a/web-app/src/config.ts +++ b/web-app/src/config.ts @@ -1,3 +1,5 @@ +import type { KasPublicKeyAlgorithm } from '@opentdf/sdk'; + export type TDFConfig = { oidc: { // eg 'http://localhost:65432/auth/realms/opentdf' @@ -7,6 +9,8 @@ export type TDFConfig = { }; kas: string; reader: string; + wrappingKeyAlgorithm?: KasPublicKeyAlgorithm; + rewrapWrappingKeyAlgorithm?: KasPublicKeyAlgorithm; }; function cfg(): TDFConfig { From d11b16cb91083174becb089c741b942a35dc0da9 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 12:24:32 -0400 Subject: [PATCH 5/9] chore(main): refresh consumer lockfiles DSPX-3229 Signed-off-by: Dave Mihalcik --- cli/package-lock.json | 59 ++++++++++++++++++++++++++++++++++++++- web-app/package-lock.json | 59 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 83e31bf45..501f9e9ab 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.17.0", "resolved": "file:../lib/opentdf-sdk-0.17.0.tgz", - "integrity": "sha512-B2tYFYvTX3SK333jl370/leq1JId/WDN51cyAN8Nq2DGfXmADL+EWBxsE/5xH3Rt7nQ8UPDalpq/G8nS2Bfl+A==", + "integrity": "sha512-3Jm0yoi+6K0bBnEZwSeFIKHKSLXeI/reVFprOogS+YXVO7xAZt3f6qZ5HefK0XS+qfyGqZnM7LjCtrHDldg0KA==", "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/package-lock.json b/web-app/package-lock.json index d46bec24b..00a4603fe 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -1076,6 +1076,62 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "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", @@ -1143,11 +1199,12 @@ "node_modules/@opentdf/sdk": { "version": "0.17.0", "resolved": "file:../lib/opentdf-sdk-0.17.0.tgz", - "integrity": "sha512-B2tYFYvTX3SK333jl370/leq1JId/WDN51cyAN8Nq2DGfXmADL+EWBxsE/5xH3Rt7nQ8UPDalpq/G8nS2Bfl+A==", + "integrity": "sha512-3Jm0yoi+6K0bBnEZwSeFIKHKSLXeI/reVFprOogS+YXVO7xAZt3f6qZ5HefK0XS+qfyGqZnM7LjCtrHDldg0KA==", "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", From 7e2f12ccd2677ae53b1a644b43b731e2feb89b14 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 16:15:41 -0400 Subject: [PATCH 6/9] fixup --- cli/package-lock.json | 2 +- web-app/package-lock.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 501f9e9ab..511fa7cd3 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -465,7 +465,7 @@ "node_modules/@opentdf/sdk": { "version": "0.17.0", "resolved": "file:../lib/opentdf-sdk-0.17.0.tgz", - "integrity": "sha512-3Jm0yoi+6K0bBnEZwSeFIKHKSLXeI/reVFprOogS+YXVO7xAZt3f6qZ5HefK0XS+qfyGqZnM7LjCtrHDldg0KA==", + "integrity": "sha512-o+NCPE6DVkNaoZ/50PEf4WyZdgnYahM4qtnw+upk5E2TlDzFlTNQFtF+AtpUGxpOvFQ5cDEagomfqfioavGlLg==", "license": "BSD-3-Clause-Clear", "dependencies": { "@connectrpc/connect": "^2.0.2", diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 00a4603fe..e86ffe34a 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -1199,7 +1199,7 @@ "node_modules/@opentdf/sdk": { "version": "0.17.0", "resolved": "file:../lib/opentdf-sdk-0.17.0.tgz", - "integrity": "sha512-3Jm0yoi+6K0bBnEZwSeFIKHKSLXeI/reVFprOogS+YXVO7xAZt3f6qZ5HefK0XS+qfyGqZnM7LjCtrHDldg0KA==", + "integrity": "sha512-o+NCPE6DVkNaoZ/50PEf4WyZdgnYahM4qtnw+upk5E2TlDzFlTNQFtF+AtpUGxpOvFQ5cDEagomfqfioavGlLg==", "license": "BSD-3-Clause-Clear", "dependencies": { "@connectrpc/connect": "^2.0.2", From df7f8d5f1a72c5a2b741dd254287e542a2e6e50c Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 17:56:08 -0400 Subject: [PATCH 7/9] fix(sdk): require kid for ML-KEM wrapped keys DSPX-3229 Signed-off-by: Dave Mihalcik --- lib/tdf3/src/models/key-access.ts | 13 ++-- lib/tdf3/src/tdf.ts | 5 ++ lib/tests/mocha/unit/key-access.test.ts | 92 ++++++++++++++++++++++++- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/lib/tdf3/src/models/key-access.ts b/lib/tdf3/src/models/key-access.ts index d3815bc75..3f18a3c97 100644 --- a/lib/tdf3/src/models/key-access.ts +++ b/lib/tdf3/src/models/key-access.ts @@ -1,4 +1,5 @@ import { base64, hex } from '../../../src/encodings/index.js'; +import { ConfigurationError } from '../../../src/errors.js'; import { Binary } from '../binary.js'; import type { CryptoService, @@ -163,13 +164,17 @@ export class MlKemWrapped { constructor( public readonly url: string, - public readonly kid: string | undefined, + public readonly kid: string, public readonly publicKey: string, public readonly algorithm: MlKemAlgorithm, public readonly metadata: unknown, public readonly cryptoService: CryptoService, public readonly sid?: string - ) {} + ) { + if (typeof kid !== 'string' || kid.trim().length === 0) { + throw new ConfigurationError('ML-KEM wrapped key access requires a non-empty kid'); + } + } async write( policy: Policy, @@ -213,6 +218,7 @@ export class MlKemWrapped { type: 'wrapped', url: this.url, protocol: 'kas', + kid: this.kid, wrappedKey: base64.encodeArrayBuffer(entityWrappedKey), encryptedMetadata: base64.encode(encryptedMetadataStr), policyBinding: { @@ -221,9 +227,6 @@ export class MlKemWrapped { }, schemaVersion, }; - if (this.kid) { - this.keyAccessObject.kid = this.kid; - } if (this.sid?.length) { this.keyAccessObject.sid = this.sid; } diff --git a/lib/tdf3/src/tdf.ts b/lib/tdf3/src/tdf.ts index a31372ad4..f746e7e96 100644 --- a/lib/tdf3/src/tdf.ts +++ b/lib/tdf3/src/tdf.ts @@ -287,6 +287,11 @@ export async function buildKeyAccess({ switch (type) { case 'wrapped': if (alg.startsWith('mlkem:')) { + if (!kid?.trim()) { + throw new ConfigurationError( + 'TDF.buildKeyAccess: ML-KEM wrapped key access requires a non-empty kid' + ); + } return new MlKemWrapped( url, kid, diff --git a/lib/tests/mocha/unit/key-access.test.ts b/lib/tests/mocha/unit/key-access.test.ts index 1ffa7aa8d..4807ccf00 100644 --- a/lib/tests/mocha/unit/key-access.test.ts +++ b/lib/tests/mocha/unit/key-access.test.ts @@ -3,9 +3,11 @@ import { expect } from 'chai'; import { ECWrapped, MlKemWrapped, Wrapped } 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, KeyPair } from '../../../tdf3/src/crypto/declarations.js'; import { Binary } from '../../../tdf3/src/binary.js'; import { importSymmetricKey } from '../../../tdf3/src/crypto/index.js'; +import { buildKeyAccess } from '../../../tdf3/src/tdf.js'; // Mock CryptoService for testing const mockCryptoService: CryptoService = { @@ -64,6 +66,7 @@ describe('ECWrapped', () => { const url = 'https://example.com'; const kid = 'test-kid'; const publicKey = 'test-public-key'; + const rawMlKemPublicKey = base64.encodeArrayBuffer(new Uint8Array(1184).buffer); const metadata = { key: 'value' }; const sid = 'test-sid'; const policy: Policy = { uuid: 'test-policy' }; @@ -128,7 +131,7 @@ describe('ECWrapped', () => { const wrapped = new MlKemWrapped( url, kid, - base64.encodeArrayBuffer(new Uint8Array(1184).buffer), + rawMlKemPublicKey, 'mlkem:768', metadata, mockCryptoService, @@ -145,6 +148,93 @@ describe('ECWrapped', () => { expect(Array.from(wrappedKey.slice(4))).to.deep.equal(Array.from(new Uint8Array(44))); }); + it('should require a non-empty kid for MlKemWrapped', () => { + expect( + () => + new MlKemWrapped( + url, + '', + rawMlKemPublicKey, + 'mlkem:768', + metadata, + mockCryptoService, + sid + ) + ).to.throw(ConfigurationError, 'ML-KEM wrapped key access requires a non-empty kid'); + }); + + it('should reject undefined kid for MlKemWrapped', () => { + expect( + () => + new MlKemWrapped( + url, + undefined as unknown as string, + rawMlKemPublicKey, + 'mlkem:768', + metadata, + mockCryptoService, + sid + ) + ).to.throw(ConfigurationError, 'ML-KEM wrapped key access requires a non-empty kid'); + }); + + it('should build an ML-KEM key access when kid is present', async () => { + const wrapped = await buildKeyAccess({ + type: 'wrapped', + alg: 'mlkem:768', + url, + kid, + publicKey: rawMlKemPublicKey, + metadata, + sid, + cryptoService: mockCryptoService, + }); + + expect(wrapped).to.be.instanceOf(MlKemWrapped); + expect(wrapped.kid).to.equal(kid); + }); + + it('should reject missing kid when building ML-KEM key access', async () => { + try { + await buildKeyAccess({ + type: 'wrapped', + alg: 'mlkem:768', + url, + publicKey: rawMlKemPublicKey, + metadata, + sid, + cryptoService: mockCryptoService, + }); + expect.fail('Expected buildKeyAccess to throw'); + } catch (error) { + expect(error).to.be.instanceOf(ConfigurationError); + expect((error as Error).message).to.include( + 'ML-KEM wrapped key access requires a non-empty kid' + ); + } + }); + + it('should reject empty kid when building ML-KEM key access', async () => { + try { + await buildKeyAccess({ + type: 'wrapped', + alg: 'mlkem:768', + url, + kid: '', + publicKey: rawMlKemPublicKey, + metadata, + sid, + cryptoService: mockCryptoService, + }); + expect.fail('Expected buildKeyAccess to throw'); + } catch (error) { + expect(error).to.be.instanceOf(ConfigurationError); + expect((error as Error).message).to.include( + 'ML-KEM wrapped key access requires a non-empty kid' + ); + } + }); + it(`should handle undefined kid for ECWrapped`, () => { const wrappedInstance = new ECWrapped(url, undefined, publicKey, metadata, mockCryptoService); expect(wrappedInstance.kid).to.be.undefined; From 5da33bdf58885c1194542ba259ccef142d1c0381 Mon Sep 17 00:00:00 2001 From: Dave Mihalcik Date: Mon, 11 May 2026 17:56:43 -0400 Subject: [PATCH 8/9] fixup nicer typescript --- lib/src/access.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index 36957c251..e52206190 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -87,17 +87,7 @@ export const rewrapAdditionalContextHeader = ( return base64.encode(JSON.stringify(context)); }; -export type KasPublicKeyAlgorithm = - | 'ec:secp256r1' - | 'ec:secp384r1' - | 'ec:secp521r1' - | 'rsa:2048' - | 'rsa:4096' - | 'mlkem:512' - | 'mlkem:768' - | 'mlkem:1024'; - -const PUBLIC_KEY_ALGORITHMS: readonly KasPublicKeyAlgorithm[] = [ +const PUBLIC_KEY_ALGORITHMS = [ 'ec:secp256r1', 'ec:secp384r1', 'ec:secp521r1', @@ -106,7 +96,9 @@ const PUBLIC_KEY_ALGORITHMS: readonly KasPublicKeyAlgorithm[] = [ 'mlkem:512', 'mlkem:768', 'mlkem:1024', -]; +] as const; + +export type KasPublicKeyAlgorithm = typeof PUBLIC_KEY_ALGORITHMS[number]; export const isPublicKeyAlgorithm = (a: string): a is KasPublicKeyAlgorithm => { return PUBLIC_KEY_ALGORITHMS.includes(a as KasPublicKeyAlgorithm); From 22f8e633ae55e5b086a074b19b5a35bb1a88f308 Mon Sep 17 00:00:00 2001 From: dmihalcik-virtru <38867245+dmihalcik-virtru@users.noreply.github.com> Date: Mon, 11 May 2026 21:57:48 +0000 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A4=96=20=F0=9F=8E=A8=20Autoformat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/access.ts | 2 +- lib/tests/mocha/unit/key-access.test.ts | 10 +--------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/lib/src/access.ts b/lib/src/access.ts index e52206190..27411ff65 100644 --- a/lib/src/access.ts +++ b/lib/src/access.ts @@ -98,7 +98,7 @@ const PUBLIC_KEY_ALGORITHMS = [ 'mlkem:1024', ] as const; -export type KasPublicKeyAlgorithm = typeof PUBLIC_KEY_ALGORITHMS[number]; +export type KasPublicKeyAlgorithm = (typeof PUBLIC_KEY_ALGORITHMS)[number]; export const isPublicKeyAlgorithm = (a: string): a is KasPublicKeyAlgorithm => { return PUBLIC_KEY_ALGORITHMS.includes(a as KasPublicKeyAlgorithm); diff --git a/lib/tests/mocha/unit/key-access.test.ts b/lib/tests/mocha/unit/key-access.test.ts index 4807ccf00..ba7044aa0 100644 --- a/lib/tests/mocha/unit/key-access.test.ts +++ b/lib/tests/mocha/unit/key-access.test.ts @@ -151,15 +151,7 @@ describe('ECWrapped', () => { it('should require a non-empty kid for MlKemWrapped', () => { expect( () => - new MlKemWrapped( - url, - '', - rawMlKemPublicKey, - 'mlkem:768', - metadata, - mockCryptoService, - sid - ) + new MlKemWrapped(url, '', rawMlKemPublicKey, 'mlkem:768', metadata, mockCryptoService, sid) ).to.throw(ConfigurationError, 'ML-KEM wrapped key access requires a non-empty kid'); });