-
Notifications
You must be signed in to change notification settings - Fork 4
feat(web-sdk): add ML-KEM-768 post-quantum key encapsulation (DSPX-2399) #934
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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'); | ||
| } | ||
| 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); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This block duplicates constants and logic already defined in |
||
|
|
||
| /** | ||
| * Import and validate a PEM public key, returning algorithm info. | ||
| * Uses JWK export for robust key parameter detection. | ||
|
|
@@ -112,6 +134,12 @@ export async function parsePublicKeyPem(pem: string): Promise<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); | ||
|
|
||
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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).