Skip to content

Commit 19f18bd

Browse files
feat(sdk): add ML-KEM (FIPS 203) key access and rewrap support (DSPX-3229)
Adds ML-KEM (Kyber, NIST FIPS 203) key access objects and rewrap support on top of the key-algorithm refactor: - PEM-ify ML-KEM keys and surface JWA names (ML-KEM-768+A192KW, ML-KEM-1024+A256KW) - require kid for ML-KEM key access - platform-interoperable key wrap; ASN.1/SPKI structure and length validation - drop ML-KEM-512 support (mlkem:768 / mlkem:1024 only) - roundtrip CI: provision ML-KEM keys via platform keygen
1 parent c25e2cd commit 19f18bd

27 files changed

Lines changed: 1791 additions & 131 deletions

.github/workflows/roundtrip/init-temp-keys.sh

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,20 @@ openssl req -x509 -nodes -newkey RSA:2048 -subj "/CN=kas" -keyout "$opt_output/k
6868
openssl ecparam -name prime256v1 >ecparams.tmp
6969
openssl req -x509 -nodes -newkey ec:ecparams.tmp -subj "/CN=kas" -keyout "$opt_output/kas-ec-private.pem" -out "$opt_output/kas-ec-cert.pem" -days 365
7070

71+
# ML-KEM KAS keys (768 & 1024). openssl on the runner is too old to emit ML-KEM,
72+
# so delegate to the platform's own keygen command — its ocrypto encoding is the
73+
# authoritative one the KAS expects, so there is no hand-rolled ASN.1 to keep in
74+
# sync. Reuses the platform clone already checked out for the roundtrip test.
75+
# script_dir must be absolute: go requires GOWORK to be an absolute path and
76+
# rejects a relative one with "invalid GOWORK: not an absolute path".
77+
script_dir="$(cd "$(dirname "$0")" >/dev/null && pwd)"
78+
if [ -d "${script_dir}/platform/service" ]; then
79+
GOWORK="${script_dir}/platform/go.work" \
80+
go run "${script_dir}/platform/service/cmd/keygen" -output "$opt_output" || exit 1
81+
else
82+
go run github.com/opentdf/platform/service/cmd/keygen@latest -output "$opt_output" || exit 1
83+
fi
84+
7185
if [ "$opt_hsm" = true ]; then
7286
pkcs11-tool --module "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_MODULEPATH}" --login --pin "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_PIN}" --write-object kas-private.pem --type privkey --label "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_KEYS_RSA_LABEL}"
7387
pkcs11-tool --module "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_MODULEPATH}" --login --pin "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_PIN}" --write-object kas-cert.pem --type cert --label "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_KEYS_RSA_LABEL}"

.github/workflows/roundtrip/opentdf.yaml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ logger:
1010
# password: changeme
1111
services:
1212
kas:
13+
# Preview features gate the non-RSA rewrap paths in newer platform builds.
14+
# ec-wrapped is needed for the default ZTDF roundtrip; mlkem for the ML-KEM tests.
15+
preview:
16+
ec_tdf_enabled: true
17+
mlkem_tdf_enabled: true
1318
keyring:
1419
- kid: e1
1520
alg: ec:secp256r1
@@ -21,6 +26,10 @@ services:
2126
- kid: r1
2227
alg: rsa:2048
2328
legacy: true
29+
- kid: mlkem768
30+
alg: mlkem:768
31+
- kid: mlkem1024
32+
alg: mlkem:1024
2433
entityresolution:
2534
url: http://localhost:65432/auth
2635
log_level: info
@@ -90,4 +99,12 @@ server:
9099
alg: ec:secp256r1
91100
private: kas-ec-private.pem
92101
cert: kas-ec-cert.pem
102+
- kid: mlkem768
103+
alg: mlkem:768
104+
private: kas-mlkem768-private.pem
105+
cert: kas-mlkem768-public.pem
106+
- kid: mlkem1024
107+
alg: mlkem:1024
108+
private: kas-mlkem1024-private.pem
109+
cert: kas-mlkem1024-public.pem
93110
port: 8080

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

cli/package-lock.json

Lines changed: 58 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/package-lock.json

Lines changed: 57 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"dependencies": {
9191
"@connectrpc/connect": "^2.0.2",
9292
"@connectrpc/connect-web": "^2.0.2",
93+
"@noble/post-quantum": "^0.6.1",
9394
"buffer-crc32": "^1.0.0",
9495
"jose": "6.0.8",
9596
"json-canonicalize": "^1.0.6",

lib/src/access.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ export const rewrapAdditionalContextHeader = (
9292
return base64.encode(JSON.stringify(context));
9393
};
9494

95+
// The supported key algorithms are defined in one place, `crypto/declarations.ts`.
96+
// These public aliases preserve the historic `access.ts` API surface (name, tuple
97+
// order, and guard behavior) while delegating to that single source of truth.
9598
export const PUBLIC_KEY_ALGORITHMS = KEY_ALGORITHMS;
9699

97100
export type KasPublicKeyAlgorithm = KeyAlgorithm;
@@ -142,6 +145,10 @@ export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => {
142145
return 'ES384';
143146
case 'ec:secp521r1':
144147
return 'ES512';
148+
case 'mlkem:768':
149+
return 'ML-KEM-768+A192KW';
150+
case 'mlkem:1024':
151+
return 'ML-KEM-1024+A256KW';
145152
default:
146153
throw new Error(`unsupported public key algorithm: ${a}`);
147154
}

lib/src/crypto/pemPublicToCrypto.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export const EC_OID = '06072a8648ce3d0201';
3939
export const P256_OID = '06082a8648ce3d030107';
4040
export const P384_OID = '06052b81040022';
4141
export const P521_OID = '06052b81040023';
42+
// NIST CSRC OID arc 2.16.840.1.101.3.4.4.{2,3} = id-alg-ml-kem-{768,1024}
43+
export const ML_KEM_768_OID = '0609608648016503040402';
44+
export const ML_KEM_1024_OID = '0609608648016503040403';
4245
const SHA_512 = 'SHA-512';
4346
const SPKI = 'spki';
4447
const CERT_BEGIN = '-----BEGIN CERTIFICATE-----';

lib/tdf3/src/client/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import { ConfigurationError } from '../../../src/errors.js';
4545
import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js';
4646
import {
4747
isEcKeyAlgorithm,
48+
isMlKemKeyAlgorithm,
4849
isRsaKeyAlgorithm,
4950
type KeyPair,
5051
type SymmetricKey,
@@ -729,7 +730,7 @@ export class Client {
729730
encryptionInformation.keyAccess = await Promise.all(
730731
splitPlan.map(async ({ kas, kid, pem, sid }) => {
731732
const algorithm = await algorithmFromPEM(pem, this.cryptoService);
732-
if (algorithm !== wrappingKeyAlgorithm) {
733+
if (wrappingKeyAlgorithm && algorithm !== wrappingKeyAlgorithm) {
733734
console.warn(
734735
`Mismatched wrapping key algorithm: [${algorithm}] is not requested type, [${wrappingKeyAlgorithm}]`
735736
);
@@ -739,6 +740,8 @@ export class Client {
739740
type = 'wrapped';
740741
} else if (isEcKeyAlgorithm(algorithm)) {
741742
type = 'ec-wrapped';
743+
} else if (isMlKemKeyAlgorithm(algorithm)) {
744+
type = 'mlkem-wrapped';
742745
} else {
743746
throw new ConfigurationError(`Unsupported algorithm ${algorithm}`);
744747
}

lib/tdf3/src/crypto/core/key-format.ts

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import {
22
ecAlgorithmToCurve,
33
isEcKeyAlgorithm,
4+
isMlKemKeyAlgorithm,
45
isRsaKeyAlgorithm,
56
type KeyAlgorithm,
67
type KeyOptions,
78
MIN_ASYMMETRIC_KEY_SIZE_BITS,
9+
mlKemAlgorithmToLevel,
810
type PrivateKey,
911
type PublicKey,
1012
type PublicKeyInfo,
@@ -17,11 +19,26 @@ import { exportSPKI, importX509 } from 'jose';
1719
import {
1820
guessAlgorithmName,
1921
guessCurveName,
22+
ML_KEM_768_OID,
23+
ML_KEM_1024_OID,
2024
toJwsAlg,
2125
} from '../../../../src/crypto/pemPublicToCrypto.js';
22-
import { unwrapKey, wrapPrivateKey, wrapPublicKey } from './keys.js';
26+
import {
27+
unwrapKey,
28+
wrapMlKemPublicKey,
29+
unwrapMlKemKey,
30+
wrapPrivateKey,
31+
wrapPublicKey,
32+
} from './keys.js';
33+
import { decodeMlKemSpkiDer, encodeMlKemSpkiDer } from './mlkem-asn1.js';
2334
import { rsaOaepSha1 } from './rsa.js';
2435

36+
function detectMlKemLevelFromHex(hex: string): 768 | 1024 | undefined {
37+
if (hex.includes(ML_KEM_768_OID)) return 768;
38+
if (hex.includes(ML_KEM_1024_OID)) return 1024;
39+
return undefined;
40+
}
41+
2542
/**
2643
* Extract PEM public key from X.509 certificate or return PEM key as-is.
2744
*/
@@ -115,6 +132,15 @@ export async function parsePublicKeyPem(pem: string): Promise<PublicKeyInfo> {
115132

116133
const keyData = base64Decode(removePemFormatting(publicKeyPem));
117134

135+
// ML-KEM: detect by OID before falling through to RSA/EC.
136+
// subtle.crypto does not (as of 2026) support ML-KEM
137+
// decodeMlKemSpkiDer also validates length so this rejects mis-encoded keys.
138+
const mlKemLevel = detectMlKemLevelFromHex(hexEncode(keyData));
139+
if (mlKemLevel !== undefined) {
140+
decodeMlKemSpkiDer(new Uint8Array(keyData));
141+
return { algorithm: `mlkem:${mlKemLevel}` as const, pem: publicKeyPem };
142+
}
143+
118144
// Try RSA first - use JWK export to get modulus size
119145
try {
120146
const modulusBits = await extractRsaModulusBitLength(keyData);
@@ -225,12 +251,31 @@ export async function publicKeyPemToJwk(publicKeyPem: string): Promise<JsonWebKe
225251

226252
/**
227253
* Import a PEM public key as an opaque key.
254+
*
255+
* Accepts standard `-----BEGIN PUBLIC KEY-----` SPKI envelopes for RSA, EC, and
256+
* ML-KEM (per draft-ietf-lamps-kyber-certificates, OIDs id-alg-ml-kem-{768,1024}).
257+
* ML-KEM keys produced by `openssl pkey -pubout` round-trip without translation.
228258
*/
229259
export async function importPublicKey(pem: string, options: KeyOptions): Promise<PublicKey> {
230260
const { usage = 'encrypt', extractable = true, algorithmHint } = options;
231261

232-
// Detect algorithm from PEM; also normalises certificates → plain SPKI PEM.
262+
// Detect algorithm from PEM; also normalises certificates → plain SPKI PEM
263+
// and identifies ML-KEM keys by OID.
233264
const keyInfo = await parsePublicKeyPem(pem);
265+
266+
// ML-KEM: import via SPKI codec. WebCrypto has no ML-KEM support, so we keep
267+
// the key as an opaque `PublicKey` carrying the raw encapsulation key bytes.
268+
if (isMlKemKeyAlgorithm(keyInfo.algorithm)) {
269+
const der = new Uint8Array(base64Decode(removePemFormatting(keyInfo.pem)));
270+
const { level, rawKey } = decodeMlKemSpkiDer(der);
271+
if (algorithmHint && algorithmHint !== `mlkem:${level}`) {
272+
throw new ConfigurationError(
273+
`ML-KEM SPKI advertises mlkem:${level} but algorithmHint is ${algorithmHint}`
274+
);
275+
}
276+
return wrapMlKemPublicKey(rawKey, level);
277+
}
278+
234279
const algorithm = algorithmHint || keyInfo.algorithm;
235280
// Use keyInfo.pem (normalised SPKI) not the original pem, which may be a certificate.
236281
// Passing raw X.509 DER bytes to crypto.subtle.importKey('spki') would throw DataError.
@@ -376,9 +421,21 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis
376421
}
377422

378423
/**
379-
* Export an opaque public key to PEM format.
424+
* Export an opaque public key to PEM SPKI format.
425+
*
426+
* ML-KEM keys are wrapped in a SubjectPublicKeyInfo envelope using the NIST
427+
* OIDs id-alg-ml-kem-{768,1024} (per draft-ietf-lamps-kyber-certificates),
428+
* so the resulting PEM is byte-compatible with `openssl pkey -pubout`.
380429
*/
381430
export async function exportPublicKeyPem(key: PublicKey): Promise<string> {
431+
if (isMlKemKeyAlgorithm(key.algorithm)) {
432+
const level = mlKemAlgorithmToLevel(key.algorithm);
433+
const der = encodeMlKemSpkiDer(unwrapMlKemKey(key), level);
434+
return formatAsPem(
435+
der.buffer.slice(der.byteOffset, der.byteOffset + der.byteLength),
436+
'PUBLIC KEY'
437+
);
438+
}
382439
const cryptoKey = unwrapKey(key);
383440
const keyBuffer = await crypto.subtle.exportKey('spki', cryptoKey);
384441
return formatAsPem(keyBuffer, 'PUBLIC KEY');

0 commit comments

Comments
 (0)