UNPKG

@noble/post-quantum

Version:

Auditable & minimal JS implementation of post-quantum cryptography: FIPS 203, 204, 205, Falcon

678 lines 32.9 kB
/** * Post-Quantum Hybrid Cryptography * * The current implementation is flawed and likely redundant. We should offer * a small, generic API to compose hybrid schemes instead of reimplementing * protocol-specific logic (SSH, GPG, etc.) with ad hoc encodings. * * 1. Core Issues * - sign/verify: implemented as two separate operations with different keys. * - EC getSharedSecret: could be refactored into a proper KEM. * - Multiple calls: keys, signatures, and shared secrets could be * concatenated to reduce the number of API invocations. * - Reinvention: most libraries add strange domain separations and * encodings instead of simple byte concatenation. * * 2. API Goals * - Provide primitives to build hybrids generically. * - Avoid embedding SSH- or GPG-specific formats in the core API. * * 3. Edge Cases * • Variable-length signatures: * - DER-encoded (Weierstrass curves). * - Falcon (unpadded). * - Concatenation works only if length is fixed; otherwise a length * prefix is required (but that breaks compatibility). * * • getSharedSecret: * - Default: non-KEM (authenticated ECDH). * - KEM conversion: generate a random SK to remove implicit auth. * * 4. Common Pitfalls * - Seed expansion: * • Expanding a small seed into multiple keys reduces entropy. * • API should allow identity mapping (no expansion). * * - Skipping full point encoding: * • Some omit the compression byte (parity) for WebCrypto compatibility. * • Better: hash the raw secret; coordinate output is already non-uniform. * • Some curves (e.g., X448) produce secrets that must be re-hashed to match * symmetric-key lengths. * * - Combiner inconsistencies: * • Different domain separations and encodings across libraries. * • Should live at the application layer, since key lengths vary. * * 5. Protocol Examples * - SSH: * • Concatenate keys. * • Combiner: SHA-512. * * - GPG: * • Concatenate keys. * • Combiner: * SHA3-256(kemShare || ecdhShare || ciphertext || pubKey || algId || domSep || len(domSep)) * * - TLS: * • Transcript-based derivation (HKDF). * * 6. Relevant Specs & Implementations * - IETF Hybrid KEM drafts: * • draft-irtf-cfrg-hybrid-kems * • draft-connolly-cfrg-xwing-kem * • draft-westerbaan-tls-xyber768d00 * * - PQC Libraries: * • superdilithium (cyph/pqcrypto.js) – low adoption. * • hybrid-pqc (DogeProtocol, quantumcoinproject) – complex encodings. * * 7. Signatures * - Ed25519: fixed-size, easy to support. * - Variable-size: introduces custom format requirements; best left to * higher-level code. * * @module */ /*! noble-post-quantum - MIT License (c) 2024 Paul Miller (paulmillr.com) */ import {} from '@noble/curves/abstract/edwards.js'; import {} from '@noble/curves/abstract/montgomery.js'; import {} from '@noble/curves/abstract/weierstrass.js'; import { x25519 } from '@noble/curves/ed25519.js'; import { p256, p384 } from '@noble/curves/nist.js'; import { asciiToBytes, bytesToNumberBE, bytesToNumberLE, concatBytes, numberToBytesBE, } from '@noble/curves/utils.js'; import { expand, extract } from '@noble/hashes/hkdf.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { sha3_256, shake256 } from '@noble/hashes/sha3.js'; import { abytes, ahash, anumber } from '@noble/hashes/utils.js'; import { ml_kem1024, ml_kem768 } from "./ml-kem.js"; import { cleanBytes, copyBytes, randomBytes, splitCoder, validateSigOpts, validateVerOpts, } from "./utils.js"; // Can re-use if decide to signatures support, on other hand getSecretKey is specific and ugly function ecKeygen(curve, allowZeroKey = false) { const lengths = curve.lengths; let keygen = curve.keygen; if (allowZeroKey) { // Only the ECDSA/Weierstrass branch uses raw scalar-byte secret keys here. Edwards seeds are // hashed/pruned and Montgomery keys are clamped byte strings, so forcing Point.Fn semantics on // those curves would change key construction instead of just relaxing scalar range handling. if (!('getSharedSecret' in curve && 'sign' in curve && 'verify' in curve)) throw new Error('allowZeroKey requires a Weierstrass curve'); // This legacy flag is really "skip the +1 shift" for vector matching, not "accept scalar 0". // It swaps seeded Weierstrass keygen from reduction into [1, ORDER) to direct reduction into // [0, ORDER), which preserves exact reduced bytes but still leaves scalar 0 invalid. // This is ugly, but we need to return exact results here. const wCurve = curve; const Fn = wCurve.Point.Fn; // Unlike noble-curves' seeded Weierstrass keygen, this path removes the post-reduction +1. // That is enough to match exact reduced-vector bytes, but an all-zero seed still reduces to // scalar 0 here and getPublicKey(secretKey) throws instead of "allowing zero". keygen = (seed = randomBytes(lengths.seed)) => { abytes(seed, lengths.seed, 'seed'); const seedScalar = Fn.isLE ? bytesToNumberLE(seed) : bytesToNumberBE(seed); // Reduce directly into [0, ORDER); scalar 0 still stays invalid. const secretKey = Fn.toBytes(Fn.create(seedScalar)); return { secretKey: secretKey, publicKey: curve.getPublicKey(secretKey), }; }; } return { lengths: { secretKey: lengths.secretKey, publicKey: lengths.publicKey, seed: lengths.seed }, keygen: (seed) => keygen(seed), getPublicKey: (secretKey) => curve.getPublicKey(secretKey), }; } /** * Wraps an ECDH-capable curve as a KEM. * Shared secrets stay in the wrapped curve's raw ECDH byte format with no built-in KDF. * On SEC 1 / Weierstrass curves, that means the compressed shared-point body without the * 1-byte `0x02` / `0x03` prefix. * The X25519 path also leaves RFC 7748's optional all-zero shared-secret check to callers. * @param curve - Curve with `getSharedSecret`. * @param allowZeroKey - Legacy vector-matching toggle for Weierstrass keygen. * On Weierstrass curves this removes the usual post-reduction `+1` shift, changing seeded scalar * reduction from `[1, ORDER)` to direct reduction into `[0, ORDER)`. It does not make scalar zero * valid: an all-zero seed still derives scalar `0` and throws in `curve.getPublicKey(...)`. * Only supported on Weierstrass/ECDSA curves. * @returns KEM wrapper over the curve. * @throws If the curve does not expose `getSharedSecret`. {@link Error} * @example * Wrap an ECDH-capable curve as a generic KEM. * ```ts * import { x25519 } from '@noble/curves/ed25519.js'; * import { ecdhKem } from '@noble/post-quantum/hybrid.js'; * const kem = ecdhKem(x25519); * const publicKeyLen = kem.lengths.publicKey; * ``` */ export function ecdhKem(curve, allowZeroKey = false) { const kg = ecKeygen(curve, allowZeroKey); if (!curve.getSharedSecret) throw new Error('wrong curve'); // ed25519 doesn't have one! return { lengths: { ...kg.lengths, msg: kg.lengths.seed, cipherText: kg.lengths.publicKey }, keygen: kg.keygen, getPublicKey: kg.getPublicKey, encapsulate(publicKey, rand = randomBytes(curve.lengths.seed)) { // Some curve.keygen(seed) paths reuse the provided seed buffer as secretKey; detach caller // randomness first so cleanBytes() only wipes wrapper-owned material. const seed = copyBytes(rand); let ek = undefined; try { ek = this.keygen(seed).secretKey; const sharedSecret = this.decapsulate(publicKey, ek); const cipherText = curve.getPublicKey(ek); return { sharedSecret, cipherText }; } finally { // Invalid peer public keys can make decapsulation throw; wipe both the detached seed and // derived ephemeral secret key even when encapsulation aborts before returning. cleanBytes(seed); if (ek) cleanBytes(ek); } }, decapsulate(cipherText, secretKey) { const res = curve.getSharedSecret(secretKey, cipherText); return (curve.lengths.publicKeyHasPrefix ? res.subarray(1) : res); }, }; } /** * Wraps a curve signer as a generic `Signer`. * Signatures stay in the wrapped curve's native byte encoding. * This wrapper does not normalize or document which per-curve signing options are meaningful. * @param curve - Curve with `sign` and `verify`. * @param allowZeroKey - Legacy vector-matching toggle for Weierstrass keygen. * On Weierstrass curves this removes the usual post-reduction `+1` shift, changing seeded scalar * reduction from `[1, ORDER)` to direct reduction into `[0, ORDER)`. It does not make scalar zero * valid: an all-zero seed still derives scalar `0` and throws in `curve.getPublicKey(...)`. * Only supported on Weierstrass/ECDSA curves. * @returns Signer wrapper over the curve. * @throws If the curve does not expose `sign` and `verify`. {@link Error} * @example * Wrap a curve signer as a generic signer. * ```ts * import { ed25519 } from '@noble/curves/ed25519.js'; * import { ecSigner } from '@noble/post-quantum/hybrid.js'; * const signer = ecSigner(ed25519); * const sigLen = signer.lengths.signature; * ``` */ export function ecSigner(curve, allowZeroKey = false) { const kg = ecKeygen(curve, allowZeroKey); if (!curve.sign || !curve.verify) throw new Error('wrong curve'); // ed25519 doesn't have one! return { lengths: { ...kg.lengths, signature: curve.lengths.signature, signRand: 0 }, keygen: kg.keygen, getPublicKey: kg.getPublicKey, sign: (message, secretKey, opts = {}) => { validateSigOpts(opts); // This generic wrapper intentionally keeps the Signer contract to message + key only. // Backend-specific knobs like ECDSA extraEntropy or Ed25519ctx context cannot be forwarded // uniformly through combineSigners(), so callers that need them must use the curve directly. if (opts.extraEntropy !== undefined) throw new Error('ecSigner does not support extraEntropy; use the underlying curve directly'); if (opts.context !== undefined) throw new Error('ecSigner does not support context; use the underlying curve directly'); return curve.sign(message, secretKey); }, /** Verify one wrapped curve signature. * Returns the wrapped curve's `verify()` result for well-formed inputs. Throws on unsupported * generic opts and lets wrapped-curve malformed-input errors escape unchanged. */ verify: (signature, message, publicKey, opts = {}) => { validateVerOpts(opts); if (opts.context !== undefined) throw new Error('ecSigner does not support context; use the underlying curve directly'); return curve.verify(signature, message, publicKey); }, }; } function splitLengths(lst, name) { // Preserve caller order exactly; raw numeric fields still decode as splitCoder() subarray views. return splitCoder(name, ...lst.map((i) => { if (typeof i.lengths[name] !== 'number') throw new Error('wrong length: ' + name); return i.lengths[name]; })); } // It is XOF for most cases, but can be more complex! /** * Adapts an XOF into an `ExpandSeed` callback. * The returned callback interprets its second argument as an output byte length passed as `dkLen`. * @param xof - Extendable-output hash function. * @returns Seed expander using `dkLen`. * @example * Adapt an XOF into a seed expander. * ```ts * import { shake256 } from '@noble/hashes/sha3.js'; * import { expandSeedXof } from '@noble/post-quantum/hybrid.js'; * const expandSeed = expandSeedXof(shake256); * const seed = expandSeed(new Uint8Array([1]), 4); * ``` */ export function expandSeedXof(xof) { // Forward the caller seed directly: XOFs are expected to treat inputs as read-only, and this // adapter only translates the requested byte length into the hash API's `dkLen` option. return ((seed, seedLen) => xof(seed, { dkLen: seedLen })); } function combineKeys(realSeedLen, // how much bytes expandSeed expects expandSeed_, ...ck_) { const expandSeed = expandSeed_; const ck = ck_; const seedCoder = splitLengths(ck, 'seed'); const pkCoder = splitLengths(ck, 'publicKey'); // Allows to use identity functions for combiner/expandSeed if (realSeedLen === undefined) realSeedLen = seedCoder.bytesLen; anumber(realSeedLen); function expandDecapsulationKey(seed) { abytes(seed, realSeedLen); const expandedRaw = expandSeed(seed, seedCoder.bytesLen); // Identity/subarray expanders can hand back caller-owned seed storage. Detach those outputs so // later cleanup can wipe the expanded schedule without mutating the caller's root seed bytes. const expandedSeed = expandedRaw.buffer === seed.buffer ? copyBytes(expandedRaw) : expandedRaw; const expanded = []; const keySecret = []; const secretKey = []; const publicKey = []; let ok = false; try { // seedCoder.decode() returns zero-copy slices into expandedSeed and can throw before child // keygen() runs, so keep the raw expanded buffer separate and copy each child seed before any // later cleanup wipes the shared backing bytes. for (const part of seedCoder.decode(expandedSeed)) expanded.push(copyBytes(part)); for (let i = 0; i < ck.length; i++) { const keys = ck[i].keygen(expanded[i]); keySecret.push(keys.secretKey); secretKey.push(copyBytes(keys.secretKey)); publicKey.push(keys.publicKey); } ok = true; return { secretKey, publicKey }; } finally { // Child keygen() can throw after deriving only a prefix of the composite key schedule. Keep // the exported copies on success, but wipe all temporary and partially built secret material // on either path so failures do not strand derived child seeds in memory. cleanBytes(expandedSeed, expanded, keySecret); if (!ok) cleanBytes(secretKey); } } return { info: { lengths: { seed: realSeedLen, publicKey: pkCoder.bytesLen, secretKey: realSeedLen } }, getPublicKey(secretKey) { // Composite secret keys are root seeds, so public-key derivation reruns key expansion from // that seed instead of decoding a packed child-secret-key structure. return this.keygen(secretKey).publicKey; }, keygen(seed = randomBytes(realSeedLen)) { const { publicKey: pk, secretKey } = expandDecapsulationKey(seed); try { const publicKey = pkCoder.encode(pk); return { secretKey: seed, publicKey }; } finally { cleanBytes(pk); // The exported secretKey is the caller/root seed itself; child secret keys are internal // expansion outputs that are cleaned whether encoding succeeds or throws. cleanBytes(secretKey); } }, expandDecapsulationKey, realSeedLen, }; } // This generic function that combines multiple KEMs into single one /** * Combines multiple KEMs into one composite KEM. * @param realSeedLen - Input seed length expected by `expandSeed`. * @param realMsgLen - Shared-secret length returned by `combiner`. * @param expandSeed - Seed expander used to derive per-KEM seeds. * @param combiner - Combines the per-KEM outputs into one shared secret. * @param kems - KEM implementations to combine. * @returns Composite KEM. * @example * Combine multiple KEMs into one composite KEM. * ```ts * import { shake256 } from '@noble/hashes/sha3.js'; * import { combineKEMS, expandSeedXof } from '@noble/post-quantum/hybrid.js'; * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; * const hybrid = combineKEMS( * 32, * 32, * expandSeedXof(shake256), * (_pk, _ct, sharedSecrets) => sharedSecrets[0], * ml_kem768, * ml_kem768 * ); * const { publicKey } = hybrid.keygen(); * ``` */ export function combineKEMS(realSeedLen, // how much bytes expandSeed expects realMsgLen, // how much bytes combiner returns expandSeed, combiner, ...kems) { const rawCombiner = combiner; const rawKems = kems; const keys = combineKeys(realSeedLen, expandSeed, ...rawKems); const ctCoder = splitLengths(rawKems, 'cipherText'); const pkCoder = splitLengths(rawKems, 'publicKey'); const msgCoder = splitLengths(rawKems, 'msg'); if (realMsgLen === undefined) realMsgLen = msgCoder.bytesLen; anumber(realMsgLen); const lengths = Object.freeze({ ...keys.info.lengths, msg: realMsgLen, msgRand: msgCoder.bytesLen, cipherText: ctCoder.bytesLen, }); return Object.freeze({ lengths, getPublicKey: keys.getPublicKey, keygen: keys.keygen, encapsulate(pk, randomness = randomBytes(msgCoder.bytesLen)) { const pks = pkCoder.decode(pk); const rand = msgCoder.decode(randomness); const sharedSecret = []; const cipherText = []; try { for (let i = 0; i < rawKems.length; i++) { const enc = rawKems[i].encapsulate(pks[i], rand[i]); sharedSecret.push(enc.sharedSecret); cipherText.push(enc.cipherText); } return { // Detach the combiner result before cleanup: a caller-provided combiner may alias one of // the child sharedSecret buffers, and those child buffers are zeroized immediately below. sharedSecret: copyBytes(rawCombiner(pks, cipherText, sharedSecret)), cipherText: ctCoder.encode(cipherText), }; } finally { // Child encapsulation or combiner failures can happen after some components already // returned secret material; zeroize whatever was produced before propagating the error. cleanBytes(sharedSecret, cipherText); } }, decapsulate(ct, seed) { const cts = ctCoder.decode(ct); const { publicKey, secretKey } = keys.expandDecapsulationKey(seed); const sharedSecret = rawKems.map((i, j) => i.decapsulate(cts[j], secretKey[j])); try { // Detach the decapsulation result before cleanup: the combiner may hand back one of the // child shared-secret buffers, and those temporary buffers are zeroized below. return copyBytes(rawCombiner(publicKey, cts, sharedSecret)); } finally { // Decapsulation only needs the expanded child secret keys and child shared secrets for this // call; keep the caller/root seed intact, but wipe all derived material even on errors. cleanBytes(secretKey, sharedSecret); } }, }); } // There is no specs for this, but can be useful // realSeedLen: how much bytes expandSeed expects. /** * Combines multiple signers into one composite signer. * @param realSeedLen - Input seed length expected by `expandSeed`. * @param expandSeed - Seed expander used to derive per-signer seeds. * @param signers - Signers to combine. * @returns Composite signer. * @example * Combine multiple signers into one composite signer. * ```ts * import { shake256 } from '@noble/hashes/sha3.js'; * import { combineSigners, expandSeedXof } from '@noble/post-quantum/hybrid.js'; * import { ml_dsa44 } from '@noble/post-quantum/ml-dsa.js'; * const hybrid = combineSigners(32, expandSeedXof(shake256), ml_dsa44, ml_dsa44); * const { publicKey } = hybrid.keygen(); * ``` */ export function combineSigners(realSeedLen, expandSeed, ...signers) { const rawSigners = signers; const keys = combineKeys(realSeedLen, expandSeed, ...rawSigners); const sigCoder = splitLengths(rawSigners, 'signature'); const pkCoder = splitLengths(rawSigners, 'publicKey'); return { lengths: { ...keys.info.lengths, signature: sigCoder.bytesLen, signRand: 0 }, getPublicKey: keys.getPublicKey, keygen: keys.keygen, sign(message, seed, opts = {}) { validateSigOpts(opts); // This generic wrapper intentionally keeps the composite signer contract to message + root // seed only. Per-signer opts like context or extraEntropy cannot be preserved uniformly // across mixed backends, so callers that need them must use the underlying signer directly. if (opts.extraEntropy !== undefined) throw new Error('combineSigners does not support extraEntropy; use the underlying signer directly'); if (opts.context !== undefined) throw new Error('combineSigners does not support context; use the underlying signer directly'); const { secretKey } = keys.expandDecapsulationKey(seed); try { const sigs = rawSigners.map((i, j) => i.sign(message, secretKey[j])); return sigCoder.encode(sigs); } finally { // Composite secret keys are root seeds; the per-signer child secret keys are temporary // expansion outputs and must not stay live after the combined signature is produced. cleanBytes(secretKey); } }, /** Verify one combined signature. * Returns `false` when the aggregate signature/publicKey decode succeeds but any child verify * check fails. Throws on unsupported generic opts or malformed aggregate encodings. */ verify: (signature, message, publicKey, opts = {}) => { validateVerOpts(opts); if (opts.context !== undefined) throw new Error('combineSigners does not support context; use the underlying signer directly'); const pks = pkCoder.decode(publicKey); const sigs = sigCoder.decode(signature); for (let i = 0; i < rawSigners.length; i++) { if (!rawSigners[i].verify(sigs[i], message, pks[i])) return false; } return true; }, }; } /** * Builds a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM. * The combined shared-secret length follows `kdf.outputLen`; the built-in presets use 32-byte * SHA3-256 output, while custom `kdf` choices inherit their own digest size. * Its combiner hashes `ss0 || ss1 || ct1 || pk1 || label`, not the full * `(c1, c2, ek1, ek2)` example input shape from SP 800-227 equation (15). * Labels are encoded with `asciiToBytes()`, so non-ASCII labels are rejected. * @param label - Domain-separation label. * @param pqc - Post-quantum KEM. * @param curveKEM - Classical curve KEM. * @param xof - XOF used for seed expansion. * @param kdf - Hash used for the final combiner. * @returns Hybrid KEM. * @example * Build a QSF hybrid KEM preset from a PQ KEM and an elliptic-curve KEM. * ```ts * import { p256 } from '@noble/curves/nist.js'; * import { sha3_256, shake256 } from '@noble/hashes/sha3.js'; * import { QSF, ecdhKem } from '@noble/post-quantum/hybrid.js'; * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; * const kem = QSF('example', ml_kem768, ecdhKem(p256, true), shake256, sha3_256); * const publicKeyLen = kem.lengths.publicKey; * ``` */ export function QSF(label, pqc, curveKEM, xof, kdf) { ahash(xof); ahash(kdf); return combineKEMS(32, kdf.outputLen, expandSeedXof(xof), (pk, ct, ss) => kdf(concatBytes(ss[0], ss[1], ct[1], pk[1], asciiToBytes(label))), pqc, curveKEM); } /** QSF preset combining ML-KEM-768 with P-256. */ export const QSF_ml_kem768_p256 = /* @__PURE__ */ (() => QSF('QSF-KEM(ML-KEM-768,P-256)-XOF(SHAKE256)-KDF(SHA3-256)', ml_kem768, ecdhKem(p256, true), shake256, sha3_256))(); /** QSF preset combining ML-KEM-1024 with P-384. */ export const QSF_ml_kem1024_p384 = /* @__PURE__ */ (() => QSF('QSF-KEM(ML-KEM-1024,P-384)-XOF(SHAKE256)-KDF(SHA3-256)', ml_kem1024, ecdhKem(p384, true), shake256, sha3_256))(); /** * Builds the "KitchenSink" hybrid KEM combiner. * The current builder always derives a fixed 32-byte output, * regardless of the hash's native output size. * Its HKDF extract step uses implicit zero salt with IKM * `hybrid_prk || ss0 || ss1 || ct0 || pk0 || ct1 || pk1 || label`. * Its HKDF expand step fixes `info` to `len || 'shared_secret' || ''`. * Labels are encoded with `asciiToBytes()`, so non-ASCII labels are rejected. * @param label - Domain-separation label. * @param pqc - Post-quantum KEM. * @param curveKEM - Classical curve KEM. * @param xof - XOF used for seed expansion. * @param hash - Hash used for HKDF extraction and expansion. * @returns Hybrid KEM. * @example * Build the "KitchenSink" hybrid KEM combiner. * ```ts * import { sha256 } from '@noble/hashes/sha2.js'; * import { shake256 } from '@noble/hashes/sha3.js'; * import { createKitchenSink, ecdhKem } from '@noble/post-quantum/hybrid.js'; * import { ml_kem768 } from '@noble/post-quantum/ml-kem.js'; * import { x25519 } from '@noble/curves/ed25519.js'; * const kem = createKitchenSink('example', ml_kem768, ecdhKem(x25519), shake256, sha256); * const publicKeyLen = kem.lengths.publicKey; * ``` */ export function createKitchenSink(label, pqc, curveKEM, xof, hash) { ahash(xof); ahash(hash); return combineKEMS(32, 32, expandSeedXof(xof), (pk, ct, ss) => { const preimage = concatBytes(ss[0], ss[1], ct[0], pk[0], ct[1], pk[1], asciiToBytes(label)); const len = 32; const ikm = concatBytes(asciiToBytes('hybrid_prk'), preimage); const prk = extract(hash, ikm); const info = concatBytes(numberToBytesBE(len, 2), asciiToBytes('shared_secret'), asciiToBytes('')); const res = expand(hash, prk, info, len); cleanBytes(prk, info, ikm, preimage); return res; }, pqc, curveKEM); } // Internal alias only: this stays exactly `ecdhKem(x25519)` // and inherits that wrapper's mutation/oracle behavior. const x25519kem = /* @__PURE__ */ ecdhKem(x25519); /** KitchenSink preset combining ML-KEM-768 with X25519. * Caller randomness splits into 32 ML-KEM coins plus a 32-byte X25519 ephemeral-secret seed. */ export const KitchenSink_ml_kem768_x25519 = /* @__PURE__ */ (() => createKitchenSink('KitchenSink-KEM(ML-KEM-768,X25519)-XOF(SHAKE256)-KDF(HKDF-SHA-256)', ml_kem768, x25519kem, shake256, sha256))(); // Always X25519 and ML-KEM - 768, no point to export /** X25519 + ML-KEM-768 hybrid preset. * Uses the hard-coded domain-separation label `\\.//^\\` and hashes only `ct1 || pk1` * from the X25519 side in addition to the two component shared secrets. */ export const ml_kem768_x25519 = /* @__PURE__ */ (() => combineKEMS(32, 32, expandSeedXof(shake256), // Awesome label, so much escaping hell in a single line. (pk, ct, ss) => sha3_256(concatBytes(ss[0], ss[1], ct[1], pk[1], asciiToBytes('\\.//^\\'))), ml_kem768, x25519kem))(); /** * Internal SEC 1-style KEM wrapper for NIST curves. * `nseed` is only the rejection-sampling byte budget for deriving one nonzero scalar: * current presets use `128` bytes for P-256 and `48` bytes for P-384. * `decapsulate()` returns the uncompressed shared point body `x || y` without the `0x04` * prefix, not the SEC 1 `x_P`-only primitive output, because current hybrid combiners hash * both coordinates. */ function nistCurveKem(curve, scalarLen, elemLen, nseed) { const Fn = curve.Point.Fn; if (!Fn) throw new Error('no Point.Fn'); // Scan scalar-sized windows until one decodes to a nonzero scalar in `[1, n-1]`; if every // window is zero or out of range, fail instead of silently reducing modulo `n`. function rejectionSampling(seed) { let sk; for (let start = 0, end = scalarLen;; start = end, end += scalarLen) { if (end > seed.length) throw new Error('rejection sampling failed'); sk = Fn.fromBytes(seed.subarray(start, end), true); if (Fn.isValidNot0(sk)) break; } const secretKey = Fn.toBytes(Fn.create(sk)); const publicKey = curve.getPublicKey(secretKey, false); return { secretKey, publicKey }; } return { lengths: { secretKey: scalarLen, publicKey: elemLen, seed: nseed, msg: nseed, cipherText: elemLen, }, keygen(seed = randomBytes(nseed)) { abytes(seed, nseed, 'seed'); return rejectionSampling(seed); }, getPublicKey(secretKey) { return curve.getPublicKey(secretKey, false); }, encapsulate(publicKey, rand = randomBytes(nseed)) { abytes(rand, nseed, 'rand'); let ek = undefined; try { ek = rejectionSampling(rand).secretKey; const sharedSecret = this.decapsulate(publicKey, ek); const cipherText = curve.getPublicKey(ek, false); return { sharedSecret, cipherText }; } finally { // Rejection-sampled NIST-curve ephemeral secret keys are temporary encapsulation state and // must be wiped even if peer-key validation or shared-secret derivation throws. if (ek) cleanBytes(ek); } }, decapsulate(cipherText, secretKey) { const full = curve.getSharedSecret(secretKey, cipherText); return full.subarray(1); }, }; } /** * Internal ML-KEM + NIST-curve combiner. * `nseed` controls only the curve-side rejection-sampling budget; it is expanded from the * 32-byte root seed and is not itself part of the exported secret-key length. * The domain-separation `label` is used only in the final `sha3_256` combiner, not in * `shake256(seed, { dkLen: 64 + nseed })`, * and the combiner hashes `ss0 || ss1 || ct1 || pk1 || label`. */ function concreteHybridKem(label, mlkem, curve, nseed) { const { secretKey: scalarLen, publicKeyUncompressed: elemLen } = curve.lengths; if (!scalarLen || !elemLen) throw new Error('wrong curve'); const curveKem = nistCurveKem(curve, scalarLen, elemLen, nseed); const mlkemSeedLen = 64; const totalSeedLen = mlkemSeedLen + nseed; return combineKEMS(32, 32, (seed) => { abytes(seed, 32); const expanded = shake256(seed, { dkLen: totalSeedLen }); const mlkemSeed = expanded.subarray(0, mlkemSeedLen); const curveSeed = expanded.subarray(mlkemSeedLen, totalSeedLen); return concatBytes(mlkemSeed, curveSeed); }, (pk, ct, ss) => sha3_256(concatBytes(ss[0], ss[1], ct[1], pk[1], asciiToBytes(label))), mlkem, curveKem); } /** P-256 + ML-KEM-768 hybrid preset. */ export const ml_kem768_p256 = /* @__PURE__ */ (() => concreteHybridKem('MLKEM768-P256', ml_kem768, p256, 128))(); /** P-384 + ML-KEM-1024 hybrid preset. */ export const ml_kem1024_p384 = /* @__PURE__ */ (() => concreteHybridKem('MLKEM1024-P384', ml_kem1024, p384, 48))(); // Legacy aliases /** Legacy alias for `ml_kem768_x25519`. */ export const XWing = /* @__PURE__ */ (() => ml_kem768_x25519)(); /** Legacy alias for `ml_kem768_x25519`. */ export const MLKEM768X25519 = /* @__PURE__ */ (() => ml_kem768_x25519)(); /** Legacy alias for `ml_kem768_p256`. */ export const MLKEM768P256 = /* @__PURE__ */ (() => ml_kem768_p256)(); /** Legacy alias for `ml_kem1024_p384`. */ export const MLKEM1024P384 = /* @__PURE__ */ (() => ml_kem1024_p384)(); /** Legacy alias for `QSF_ml_kem768_p256`. */ export const QSFMLKEM768P256 = /* @__PURE__ */ (() => QSF_ml_kem768_p256)(); /** Legacy alias for `QSF_ml_kem1024_p384`. */ export const QSFMLKEM1024P384 = /* @__PURE__ */ (() => QSF_ml_kem1024_p384)(); /** Legacy alias for `KitchenSink_ml_kem768_x25519`. */ export const KitchenSinkMLKEM768X25519 = /* @__PURE__ */ (() => KitchenSink_ml_kem768_x25519)(); //# sourceMappingURL=hybrid.js.map