Files
client/node_modules/@noble/post-quantum/hybrid.js
T

678 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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