678 lines
32 KiB
JavaScript
678 lines
32 KiB
JavaScript
/**
|
||
* 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
|