Skip to content
Draft
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
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
5 changes: 4 additions & 1 deletion lib/src/access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,12 @@ export type KasPublicKeyAlgorithm =
| 'ec:secp256r1'
| 'ec:secp384r1'
| 'ec:secp521r1'
| 'mlkem:768'
| 'rsa:2048'
| 'rsa:4096';

export const isPublicKeyAlgorithm = (a: string): a is KasPublicKeyAlgorithm => {
return a === 'ec:secp256r1' || a === 'rsa:2048';
return a === 'ec:secp256r1' || a === 'mlkem:768' || a === 'rsa:2048';
};

export const keyAlgorithmToPublicKeyAlgorithm = (k: CryptoKey): KasPublicKeyAlgorithm => {
Expand Down Expand Up @@ -142,6 +143,8 @@ export const publicKeyAlgorithmToJwa = (a: KasPublicKeyAlgorithm): string => {
return 'ES384';
case 'ec:secp521r1':
return 'ES512';
case 'mlkem:768':
return 'ML-KEM-768';
default:
throw new Error(`unsupported public key algorithm: ${a}`);
}
Expand Down
1 change: 1 addition & 0 deletions lib/src/crypto/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum AlgorithmName {
ECDSA = 'ECDSA',
ES256 = 'ES256',
HKDF = 'HKDF',
MLKEM_768 = 'ML-KEM-768',
RSA_OAEP = 'RSA-OAEP',
RSA_PSS = 'RSA-PSS',
}
Expand Down
67 changes: 67 additions & 0 deletions lib/src/crypto/mlkem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { ml_kem768 } from '@noble/post-quantum/ml-kem.js';
import { decodeArrayBuffer as base64Decode } from '../encodings/base64.js';
import { encodeArrayBuffer as hexEncode } from '../encodings/hex.js';

export const MLKEM768_OID_HEX = '0609608648016503040402';
export const MLKEM768_EK_BYTES = 1184;
export const MLKEM768_CT_BYTES = 1088;

// Bytes before the raw key in a well-formed ML-KEM-768 SPKI:
// 4 outer SEQUENCE header + 13 AlgorithmIdentifier + 4 BIT STRING header + 1 unused-bits = 22
const MLKEM768_SPKI_EK_OFFSET = 22;

/**
* Extract the raw 1184-byte encapsulation key from an ML-KEM-768 SPKI blob.
*/
export function extractMLKEM768EkFromSpki(spkiBytes: ArrayBuffer): Uint8Array {
const hex = hexEncode(spkiBytes);
if (!hex.includes(MLKEM768_OID_HEX)) {
throw new Error('Not an ML-KEM-768 SPKI public key');
}
Comment on lines +18 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using hex.includes(MLKEM768_OID_HEX) to validate the SPKI structure is potentially unreliable, as the OID byte sequence could theoretically appear within the pseudorandom key material. Since the SPKI header for ML-KEM-768 has a fixed length, it is safer to verify the OID at its expected position (offset 12 in the hex string for a standard DER-encoded SPKI).

Suggested change
if (!hex.includes(MLKEM768_OID_HEX)) {
throw new Error('Not an ML-KEM-768 SPKI public key');
}
if (hex.substring(12, 12 + MLKEM768_OID_HEX.length) !== MLKEM768_OID_HEX) {
throw new Error('Not an ML-KEM-768 SPKI public key');
}

const view = new Uint8Array(spkiBytes);
if (view.length !== MLKEM768_SPKI_EK_OFFSET + MLKEM768_EK_BYTES) {
throw new Error(`Invalid ML-KEM-768 SPKI length: ${view.length}`);
}
return view.slice(MLKEM768_SPKI_EK_OFFSET);
}

/**
* Parse an ML-KEM-768 PEM public key and return the raw 1184-byte encapsulation key.
*/
export function parseMLKEM768PublicKeyPem(pem: string): Uint8Array {
const b64 = pem.replace(/-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----|\s/g, '');
const spkiBytes = base64Decode(b64);
return extractMLKEM768EkFromSpki(spkiBytes);
}

/**
* Encapsulate using ML-KEM-768: returns 1088-byte ciphertext and 32-byte shared secret.
*/
export function mlkem768Encapsulate(ek: Uint8Array): {
cipherText: Uint8Array;
sharedSecret: Uint8Array;
} {
return ml_kem768.encapsulate(ek);
}

/**
* AES-256 Key Wrap (RFC 3394) of raw key bytes using the given 32-byte wrapping key.
* Returns 40 bytes for a 32-byte input key.
*/
export async function aesKwWrap(
keyBytes: Uint8Array,
wrappingKeyBytes: Uint8Array
): Promise<Uint8Array> {
const wrappingKey = await crypto.subtle.importKey('raw', wrappingKeyBytes, 'AES-KW', false, [
'wrapKey',
]);
const keyToWrap = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const wrapped = await crypto.subtle.wrapKey('raw', keyToWrap, wrappingKey, 'AES-KW');
return new Uint8Array(wrapped);
}
1 change: 1 addition & 0 deletions lib/tdf3/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,7 @@ export class Client {
switch (algorithm) {
case 'rsa:2048':
case 'rsa:4096':
case 'mlkem:768':
type = 'wrapped';
break;
case 'ec:secp384r1':
Expand Down
28 changes: 28 additions & 0 deletions lib/tdf3/src/crypto/core/key-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,28 @@ async function extractRsaModulusBitLength(keyData: ArrayBuffer): Promise<number>
return base64urlByteLength(jwk.n) * 8;
}

// ML-KEM-768 OID: 2.16.840.1.101.3.4.4.2 encoded in DER as tag (06) + length (09) + value
const MLKEM768_OID_HEX = '0609608648016503040402';
const MLKEM768_EK_BYTES = 1184;
// Bytes before the raw key in a well-formed ML-KEM-768 SPKI blob:
// 4 outer SEQUENCE header + 13 AlgorithmIdentifier + 4 BIT STRING header + 1 unused-bits = 22
const MLKEM768_SPKI_EK_OFFSET = 22;

/**
* Extract the 1184-byte ML-KEM-768 encapsulation key from a SPKI-encoded public key.
*/
export function extractMLKEM768EkFromSpki(spkiBytes: ArrayBuffer): Uint8Array {
const hex = hexEncode(spkiBytes);
if (!hex.includes(MLKEM768_OID_HEX)) {
throw new ConfigurationError('Not an ML-KEM-768 SPKI public key');
}
const view = new Uint8Array(spkiBytes);
if (view.length !== MLKEM768_SPKI_EK_OFFSET + MLKEM768_EK_BYTES) {
throw new ConfigurationError(`Invalid ML-KEM-768 SPKI length: ${view.length}`);
}
return view.slice(MLKEM768_SPKI_EK_OFFSET);
}
Comment on lines +98 to +118
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block duplicates constants and logic already defined in lib/src/crypto/mlkem.ts. To maintain a single source of truth and improve maintainability, these should be imported from the core crypto module (e.g., import { MLKEM768_OID_HEX, MLKEM768_EK_BYTES, extractMLKEM768EkFromSpki } from '../../../../src/crypto/mlkem.js'). If you need to preserve the ConfigurationError type specifically for this module, you can wrap the imported function call.


/**
* Import and validate a PEM public key, returning algorithm info.
* Uses JWK export for robust key parameter detection.
Expand All @@ -112,6 +134,12 @@ export async function parsePublicKeyPem(pem: string): Promise<PublicKeyInfo> {

const keyData = base64Decode(removePemFormatting(publicKeyPem));

// Check for ML-KEM-768 by OID before attempting WebCrypto import
const keyHex = hexEncode(keyData);
if (keyHex.includes(MLKEM768_OID_HEX)) {
return { algorithm: 'mlkem:768', pem: publicKeyPem };
}

// Try RSA first - use JWK export to get modulus size
try {
const modulusBits = await extractRsaModulusBitLength(keyData);
Expand Down
11 changes: 9 additions & 2 deletions lib/tdf3/src/crypto/declarations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export type KeyAlgorithm =
| 'rsa:4096'
| 'ec:secp256r1'
| 'ec:secp384r1'
| 'ec:secp521r1';
| 'ec:secp521r1'
| 'mlkem:768';

/**
* Options for key generation and import.
Expand Down Expand Up @@ -156,7 +157,13 @@ export type HkdfParams = {
*/
export type PublicKeyInfo = {
/** Detected algorithm of the key. */
algorithm: 'rsa:2048' | 'rsa:4096' | 'ec:secp256r1' | 'ec:secp384r1' | 'ec:secp521r1';
algorithm:
| 'rsa:2048'
| 'rsa:4096'
| 'ec:secp256r1'
| 'ec:secp384r1'
| 'ec:secp521r1'
| 'mlkem:768';
/** Normalized PEM string. */
pem: string;
};
Expand Down
76 changes: 75 additions & 1 deletion lib/tdf3/src/models/key-access.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import type { CryptoService, KeyPair, SymmetricKey } from '../crypto/declaration
import { getZtdfSalt } from '../crypto/salt.js';
import { Algorithms } from '../ciphers/index.js';
import { Policy } from './policy.js';
import { unwrapSymmetricKey } from '../crypto/core/keys.js';
import {
mlkem768Encapsulate,
parseMLKEM768PublicKeyPem,
aesKwWrap,
MLKEM768_CT_BYTES,
} from '../../../src/crypto/mlkem.js';

export type KeyAccessType = 'remote' | 'wrapped' | 'ec-wrapped';

Expand Down Expand Up @@ -152,7 +159,74 @@ export class Wrapped {
}
}

export type KeyAccess = ECWrapped | Wrapped;
/**
* ML-KEM-768 post-quantum key encapsulation mechanism.
* Wire format: type="wrapped", wrappedKey=base64(ciphertext[1088] || aes_kw_wrapped_dek), no ephemeralPublicKey.
*/
export class MLKEMWrapped {
readonly type = 'wrapped' as const;
keyAccessObject?: KeyAccessObject;

constructor(
public readonly url: string,
public readonly kid: string | undefined,
public readonly publicKey: string,
public readonly metadata: unknown,
public readonly cryptoService: CryptoService,
public readonly sid?: string
) {}

async write(
policy: Policy,
dek: SymmetricKey,
encryptedMetadataStr: string
): Promise<KeyAccessObject> {
const policyStr = JSON.stringify(policy);

// Parse ML-KEM-768 encapsulation key from PEM
const ek = parseMLKEM768PublicKeyPem(this.publicKey);

// ML-KEM-768 encapsulation: produces 1088-byte ciphertext and 32-byte shared secret
const { cipherText, sharedSecret } = mlkem768Encapsulate(ek);

// AES-256 Key Wrap the DEK with the shared secret
const dekBytes = unwrapSymmetricKey(dek);
const wrappedDek = await aesKwWrap(dekBytes, sharedSecret);

// Wire format: ciphertext || aes_kw_wrapped_dek
const wrappedKeyBytes = new Uint8Array(MLKEM768_CT_BYTES + wrappedDek.length);
wrappedKeyBytes.set(cipherText, 0);
wrappedKeyBytes.set(wrappedDek, MLKEM768_CT_BYTES);

const policyBinding = hex.encodeArrayBuffer(
(await this.cryptoService.hmac(new TextEncoder().encode(base64.encode(policyStr)), dek))
.buffer
);

this.keyAccessObject = {
type: 'wrapped',
url: this.url,
protocol: 'kas',
wrappedKey: base64.encodeArrayBuffer(wrappedKeyBytes.buffer),
encryptedMetadata: base64.encode(encryptedMetadataStr),
policyBinding: {
alg: 'HS256',
hash: base64.encode(policyBinding),
},
schemaVersion,
};
if (this.kid) {
this.keyAccessObject.kid = this.kid;
}
if (this.sid?.length) {
this.keyAccessObject.sid = this.sid;
}

return this.keyAccessObject;
}
}

export type KeyAccess = ECWrapped | MLKEMWrapped | Wrapped;

/**
* A KeyAccess object stores all information about how an object key OR one key split is stored.
Expand Down
4 changes: 4 additions & 0 deletions lib/tdf3/src/tdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
KeyAccessType,
KeyInfo,
Manifest,
MLKEMWrapped,
Policy,
SplitKey,
Wrapped,
Expand Down Expand Up @@ -280,6 +281,9 @@ export async function buildKeyAccess({
}
switch (type) {
case 'wrapped':
if (alg === 'mlkem:768') {
return new MLKEMWrapped(url, kid, pubKey, metadata, cryptoService, sid);
}
return new Wrapped(url, kid, pubKey, metadata, cryptoService, sid);
case 'ec-wrapped':
return new ECWrapped(url, kid, pubKey, metadata, cryptoService, sid);
Expand Down
23 changes: 23 additions & 0 deletions xtest/sdk/js/cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Cross-platform test harness wrapper for the OpenTDF JS CLI.
# Usage: cli.sh supports <feature>
# cli.sh <cli-args...>
set -eu

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"

case "${1:-}" in
supports)
case "${2:-}" in
mechanism-mlkem)
exit 0
;;
*)
exit 1
;;
esac
;;
*)
exec node "${REPO_ROOT}/cli/bin/opentdf.mjs" "$@"
;;
esac