Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .github/workflows/roundtrip/init-temp-keys.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ openssl req -x509 -nodes -newkey RSA:2048 -subj "/CN=kas" -keyout "$opt_output/k
openssl ecparam -name prime256v1 >ecparams.tmp
openssl req -x509 -nodes -newkey ec:ecparams.tmp -subj "/CN=kas" -keyout "$opt_output/kas-ec-private.pem" -out "$opt_output/kas-ec-cert.pem" -days 365

# ML-KEM KAS keys (768 & 1024). openssl on the runner is too old to emit ML-KEM,
# so delegate to the platform's own keygen command — its ocrypto encoding is the
# authoritative one the KAS expects, so there is no hand-rolled ASN.1 to keep in
# sync. Reuses the platform clone already checked out for the roundtrip test.
# script_dir must be absolute: go requires GOWORK to be an absolute path and
# rejects a relative one with "invalid GOWORK: not an absolute path".
script_dir="$(cd "$(dirname "$0")" >/dev/null && pwd)"
if [ -d "${script_dir}/platform/service" ]; then
GOWORK="${script_dir}/platform/go.work" \
go run "${script_dir}/platform/service/cmd/keygen" -output "$opt_output" || exit 1
else
go run github.com/opentdf/platform/service/cmd/keygen@latest -output "$opt_output" || exit 1
fi

if [ "$opt_hsm" = true ]; then
pkcs11-tool --module "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_MODULEPATH}" --login --pin "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_PIN}" --write-object kas-private.pem --type privkey --label "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_KEYS_RSA_LABEL}"
pkcs11-tool --module "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_MODULEPATH}" --login --pin "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_PIN}" --write-object kas-cert.pem --type cert --label "${OPENTDF_SERVER_CRYPTOPROVIDER_HSM_KEYS_RSA_LABEL}"
Expand Down
17 changes: 17 additions & 0 deletions .github/workflows/roundtrip/opentdf.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ logger:
# password: changeme
services:
kas:
# Preview features gate the non-RSA rewrap paths in newer platform builds.
# ec-wrapped is needed for the default ZTDF roundtrip; mlkem for the ML-KEM tests.
preview:
ec_tdf_enabled: true
mlkem_tdf_enabled: true
keyring:
- kid: e1
alg: ec:secp256r1
Expand All @@ -21,6 +26,10 @@ services:
- kid: r1
alg: rsa:2048
legacy: true
- kid: mlkem768
alg: mlkem:768
- kid: mlkem1024
alg: mlkem:1024
entityresolution:
url: http://localhost:65432/auth
log_level: info
Expand Down Expand Up @@ -90,4 +99,12 @@ server:
alg: ec:secp256r1
private: kas-ec-private.pem
cert: kas-ec-cert.pem
- kid: mlkem768
alg: mlkem:768
private: kas-mlkem768-private.pem
cert: kas-mlkem768-public.pem
- kid: mlkem1024
alg: mlkem:1024
private: kas-mlkem1024-private.pem
cert: kas-mlkem1024-public.pem
port: 8080
1 change: 1 addition & 0 deletions CLAUDE.md
59 changes: 58 additions & 1 deletion cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 57 additions & 0 deletions lib/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions lib/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export const rewrapAdditionalContextHeader = (
return base64.encode(JSON.stringify(context));
};

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

export type KasPublicKeyAlgorithm = KeyAlgorithm;
Expand Down Expand Up @@ -142,6 +145,10 @@ export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => {
return 'ES384';
case 'ec:secp521r1':
return 'ES512';
case 'mlkem:768':
return 'ML-KEM-768+A192KW';
case 'mlkem:1024':
return 'ML-KEM-1024+A256KW';
default:
throw new Error(`unsupported public key algorithm: ${a}`);
}
Expand Down
3 changes: 3 additions & 0 deletions lib/src/crypto/pemPublicToCrypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ export const EC_OID = '06072a8648ce3d0201';
export const P256_OID = '06082a8648ce3d030107';
export const P384_OID = '06052b81040022';
export const P521_OID = '06052b81040023';
// NIST CSRC OID arc 2.16.840.1.101.3.4.4.{2,3} = id-alg-ml-kem-{768,1024}
export const ML_KEM_768_OID = '0609608648016503040402';
export const ML_KEM_1024_OID = '0609608648016503040403';
const SHA_512 = 'SHA-512';
const SPKI = 'spki';
const CERT_BEGIN = '-----BEGIN CERTIFICATE-----';
Expand Down
5 changes: 4 additions & 1 deletion lib/tdf3/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { ConfigurationError } from '../../../src/errors.js';
import { AesGcmCipher } from '../ciphers/aes-gcm-cipher.js';
import {
isEcKeyAlgorithm,
isMlKemKeyAlgorithm,
isRsaKeyAlgorithm,
type KeyPair,
type SymmetricKey,
Expand Down Expand Up @@ -729,7 +730,7 @@ export class Client {
encryptionInformation.keyAccess = await Promise.all(
splitPlan.map(async ({ kas, kid, pem, sid }) => {
const algorithm = await algorithmFromPEM(pem, this.cryptoService);
if (algorithm !== wrappingKeyAlgorithm) {
if (wrappingKeyAlgorithm && algorithm !== wrappingKeyAlgorithm) {
console.warn(
`Mismatched wrapping key algorithm: [${algorithm}] is not requested type, [${wrappingKeyAlgorithm}]`
);
Expand All @@ -739,6 +740,8 @@ export class Client {
type = 'wrapped';
} else if (isEcKeyAlgorithm(algorithm)) {
type = 'ec-wrapped';
} else if (isMlKemKeyAlgorithm(algorithm)) {
type = 'mlkem-wrapped';
} else {
throw new ConfigurationError(`Unsupported algorithm ${algorithm}`);
}
Expand Down
63 changes: 60 additions & 3 deletions lib/tdf3/src/crypto/core/key-format.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
ecAlgorithmToCurve,
isEcKeyAlgorithm,
isMlKemKeyAlgorithm,
isRsaKeyAlgorithm,
type KeyAlgorithm,
type KeyOptions,
MIN_ASYMMETRIC_KEY_SIZE_BITS,
mlKemAlgorithmToLevel,
type PrivateKey,
type PublicKey,
type PublicKeyInfo,
Expand All @@ -17,11 +19,26 @@ import { exportSPKI, importX509 } from 'jose';
import {
guessAlgorithmName,
guessCurveName,
ML_KEM_768_OID,
ML_KEM_1024_OID,
toJwsAlg,
} from '../../../../src/crypto/pemPublicToCrypto.js';
import { unwrapKey, wrapPrivateKey, wrapPublicKey } from './keys.js';
import {
unwrapKey,
wrapMlKemPublicKey,
unwrapMlKemKey,
wrapPrivateKey,
wrapPublicKey,
} from './keys.js';
import { decodeMlKemSpkiDer, encodeMlKemSpkiDer } from './mlkem-asn1.js';
import { rsaOaepSha1 } from './rsa.js';

function detectMlKemLevelFromHex(hex: string): 768 | 1024 | undefined {
if (hex.includes(ML_KEM_768_OID)) return 768;
if (hex.includes(ML_KEM_1024_OID)) return 1024;
return undefined;
}

/**
* Extract PEM public key from X.509 certificate or return PEM key as-is.
*/
Expand Down Expand Up @@ -115,6 +132,15 @@ export async function parsePublicKeyPem(pem: string): Promise<PublicKeyInfo> {

const keyData = base64Decode(removePemFormatting(publicKeyPem));

// ML-KEM: detect by OID before falling through to RSA/EC.
// subtle.crypto does not (as of 2026) support ML-KEM
// decodeMlKemSpkiDer also validates length so this rejects mis-encoded keys.
const mlKemLevel = detectMlKemLevelFromHex(hexEncode(keyData));
if (mlKemLevel !== undefined) {
decodeMlKemSpkiDer(new Uint8Array(keyData));
return { algorithm: `mlkem:${mlKemLevel}` as const, pem: publicKeyPem };
}

// Try RSA first - use JWK export to get modulus size
try {
const modulusBits = await extractRsaModulusBitLength(keyData);
Expand Down Expand Up @@ -225,12 +251,31 @@ export async function publicKeyPemToJwk(publicKeyPem: string): Promise<JsonWebKe

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

// Detect algorithm from PEM; also normalises certificates → plain SPKI PEM.
// Detect algorithm from PEM; also normalises certificates → plain SPKI PEM
// and identifies ML-KEM keys by OID.
const keyInfo = await parsePublicKeyPem(pem);

// ML-KEM: import via SPKI codec. WebCrypto has no ML-KEM support, so we keep
// the key as an opaque `PublicKey` carrying the raw encapsulation key bytes.
if (isMlKemKeyAlgorithm(keyInfo.algorithm)) {
const der = new Uint8Array(base64Decode(removePemFormatting(keyInfo.pem)));
const { level, rawKey } = decodeMlKemSpkiDer(der);
if (algorithmHint && algorithmHint !== `mlkem:${level}`) {
throw new ConfigurationError(
`ML-KEM SPKI advertises mlkem:${level} but algorithmHint is ${algorithmHint}`
);
}
return wrapMlKemPublicKey(rawKey, level);
}

const algorithm = algorithmHint || keyInfo.algorithm;
// Use keyInfo.pem (normalised SPKI) not the original pem, which may be a certificate.
// Passing raw X.509 DER bytes to crypto.subtle.importKey('spki') would throw DataError.
Expand Down Expand Up @@ -376,9 +421,21 @@ export async function importPrivateKey(pem: string, options: KeyOptions): Promis
}

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