@noble/post-quantum
Version:
Auditable & minimal JS implementation of post-quantum cryptography: FIPS 203, 204, 205, Falcon
444 lines • 20.1 kB
JavaScript
/**
* ML-KEM: Module Lattice-based Key Encapsulation Mechanism from
* [FIPS-203](https://csrc.nist.gov/pubs/fips/203/ipd). A.k.a. CRYSTALS-Kyber.
*
* Key encapsulation is similar to DH / ECDH (think X25519), with important differences:
* * Unlike in ECDH, we can't verify if it was "Bob" who've sent the shared secret
* * Unlike ECDH, it is probabalistic and relies on quality of randomness (CSPRNG).
* * Decapsulation never throws an error, even when shared secret was
* encrypted by a different public key. It will just return a different shared secret.
*
* There are some concerns with regards to security: see
* [djb blog](https://blog.cr.yp.to/20231003-countcorrectly.html) and
* [mailing list](https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/W2VOzy0wz_E).
*
* Has similar internals to ML-DSA, but their keys and params are different.
*
* Check out [official site](https://www.pq-crystals.org/kyber/resources.shtml),
* [repo](https://github.com/pq-crystals/kyber),
* [spec](https://datatracker.ietf.org/doc/draft-cfrg-schwabe-kyber/).
* @module
*/
/*! noble-post-quantum - MIT License (c) 2024 Paul Miller (paulmillr.com) */
import { sha3_256, sha3_512, shake256 } from '@noble/hashes/sha3.js';
import { swap32IfBE, u32 } from '@noble/hashes/utils.js';
import { genCrystals, XOF128 } from "./_crystals.js";
import { abytes, cleanBytes, copyBytes, equalBytes, getMask, randomBytes, splitCoder, vecCoder, } from "./utils.js";
/** Key encapsulation mechanism interface */
const N = 256; // Kyber (not FIPS-203) supports different lengths, but all std modes were using 256
const Q = 3329; // 13*(2**8)+1, modulo prime
const F = 3303; // 3303 ≡ 128**(−1) mod q (FIPS-203)
const ROOT_OF_UNITY = 17; // ζ = 17 ∈ Zq is a primitive 256-th root of unity modulo Q. ζ**128 ≡−1
// treeshake: keep genCrystals behind the object so PARAMS-only bundles can drop it entirely.
// Shared CRYSTALS helper in the ML-KEM branch: Kyber mode, 7-bit bit-reversal,
// and Uint16Array polys because current coefficients stay reduced modulo q.
const crystals = /* @__PURE__ */ genCrystals({
N,
Q,
F,
ROOT_OF_UNITY,
newPoly: (n) => new Uint16Array(n),
brvBits: 7,
isKyber: true,
});
/** Internal params of ML-KEM versions */
// prettier-ignore
/** Built-in ML-KEM parameter presets keyed by the public export names
* `ml_kem512` / `ml_kem768` / `ml_kem1024`.
* `RBGstrength` is Table 2's required randomness-source strength in bits,
* not a generic security label.
*/
export const PARAMS = /* @__PURE__ */ (() => Object.freeze({
512: Object.freeze({ N, Q, K: 2, ETA1: 3, ETA2: 2, du: 10, dv: 4, RBGstrength: 128 }),
768: Object.freeze({ N, Q, K: 3, ETA1: 2, ETA2: 2, du: 10, dv: 4, RBGstrength: 192 }),
1024: Object.freeze({ N, Q, K: 4, ETA1: 2, ETA2: 2, du: 11, dv: 5, RBGstrength: 256 }),
}))();
// FIPS-203: compress/decompress
const compress = (d) => {
// d=12 is the ByteEncode12/ByteDecode12 path, not lossy compression.
// ByteDecode12 interprets each 12-bit word modulo q; without that reduction the public-key
// modulus check in encapsulate() becomes a no-op for malformed coefficients like 4095.
if (d >= 12)
return { encode: (i) => i, decode: (i) => (i >= Q ? i - Q : i) };
// Comments map to python implementation in RFC (draft-cfrg-schwabe-kyber)
// const round = (i: number) => Math.floor(i + 0.5) | 0;
const a = 2 ** (d - 1);
return {
// This only matches standalone Compress_d after bitsCoder masks the result into Z_(2^d).
encode: (i) => ((i << d) + Q / 2) / Q,
// const decompress = (i: number) => round((Q / 2 ** d) * i);
decode: (i) => (i * Q + a) >>> d,
};
};
// Raw ByteEncode_d / ByteDecode_d from FIPS 203 operate on d-bit words directly.
// That differs from `polyCoder(d)` for d<12, where noble folds packing together with the lossy
// ciphertext compression step used by u/v. Tests that exercise the spec's raw packing surface need
// this exact non-lossy variant instead.
const byteCoder = (d) => crystals.bitsCoder(d, d === 12
? { encode: (i) => i, decode: (i) => (i >= Q ? i - Q : i) }
: { encode: (i) => i, decode: (i) => i });
// NOTE: we merge encoding and compress because it is faster, also both require same d param
// d=12 is the ByteEncode12/ByteDecode12 path rather than compression, and caller-side
// public-key modulus checks route through this helper's decode/encode roundtrip.
// Converts between bytes and d-bits compressed representation.
// Kinda like convertRadix2 from @scure/base.
// decode(encode(t)) == t, but there is loss of information on encode(decode(t))
const polyCoder = (d) => (d === 12 ? byteCoder(12) : crystals.bitsCoder(d, compress(d)));
function polyAdd(a_, b_) {
const a = a_;
const b = b_;
// Mutates `a` in place; callers must pass two N=256 polynomials.
for (let i = 0; i < N; i++)
a[i] = crystals.mod(a[i] + b[i]); // a += b
}
function polySub(a_, b_) {
const a = a_;
const b = b_;
// Mutates `a` in place; callers must pass two N=256 polynomials.
for (let i = 0; i < N; i++)
a[i] = crystals.mod(a[i] - b[i]); // a -= b
}
// FIPS-203: Computes the product of two degree-one polynomials with respect to a quadratic modulus
function BaseCaseMultiply(a0, a1, b0, b1, zeta) {
// `zeta` here is Algorithm 11's γ = ζ^(2BitRev_7(i)+1).
const c0 = crystals.mod(a1 * b1 * zeta + a0 * b0);
const c1 = crystals.mod(a0 * b1 + a1 * b0);
return { c0, c1 };
}
// FIPS-203: Computes the product (in the ring Tq) of two NTT representations.
// Works in place on `f`; `g` is read-only and both inputs must already be in NTT form.
function MultiplyNTTs(f_, g_) {
const f = f_;
const g = g_;
for (let i = 0; i < N / 2; i++) {
let z = crystals.nttZetas[64 + (i >> 1)];
if (i & 1)
z = -z;
const { c0, c1 } = BaseCaseMultiply(f[2 * i + 0], f[2 * i + 1], g[2 * i + 0], g[2 * i + 1], z);
f[2 * i + 0] = c0;
f[2 * i + 1] = c1;
}
return f;
}
// Return poly in NTT representation
function SampleNTT(xof_) {
const xof = xof_;
// The reader must already bind the Algorithm 7 seed||j||i bytes
// and return block lengths divisible by 3.
const r = new Uint16Array(N);
for (let j = 0; j < N;) {
const b = xof();
if (b.length % 3)
throw new Error('SampleNTT: unaligned block');
for (let i = 0; j < N && i + 3 <= b.length; i += 3) {
const d1 = ((b[i + 0] >> 0) | (b[i + 1] << 8)) & 0xfff;
const d2 = ((b[i + 1] >> 4) | (b[i + 2] << 4)) & 0xfff;
if (d1 < Q)
r[j++] = d1;
if (j < N && d2 < Q)
r[j++] = d2;
}
}
return r;
}
// Sampling from the centered binomial distribution
// Returns poly with small coefficients (noise/errors) stored modulo q in ordinary coefficient form.
// Current callers only use Table 2 eta values {2,3} and PRF outputs of exactly 64*eta bytes.
const sampleCBDBytes = (buf, eta) => {
const r = new Uint16Array(N);
// CBD consumes the PRF bitstream in little-endian byte order; normalize the word view on BE,
// then swap it back so callers still observe `buf` as read-only.
const b32 = u32(buf);
swap32IfBE(b32);
let len = 0;
for (let i = 0, p = 0, bb = 0, t0 = 0; i < b32.length; i++) {
let b = b32[i];
for (let j = 0; j < 32; j++) {
bb += b & 1;
b >>= 1;
len += 1;
if (len === eta) {
t0 = bb;
bb = 0;
}
else if (len === 2 * eta) {
r[p++] = crystals.mod(t0 - bb);
bb = 0;
len = 0;
}
}
}
swap32IfBE(b32);
if (len)
throw new Error(`sampleCBD: leftover bits: ${len}`);
return r;
};
function sampleCBD(PRF_, seed, nonce, eta) {
const PRF = PRF_;
return sampleCBDBytes(PRF((eta * N) / 4, seed, nonce), eta);
}
// K-PKE
// Internal ML-KEM subroutine only: exact 32-byte `seed` / `msg` inputs
// come from Algorithms 13-15, and the helper mutates decoded temporary
// polynomials in place while leaving caller byte arrays unchanged.
const genKPKE = (opts_) => {
const opts = opts_;
const { K, PRF, XOF, HASH512, ETA1, ETA2, du, dv } = opts;
const poly1 = polyCoder(1);
const polyV = polyCoder(dv);
const polyU = polyCoder(du);
const publicCoder = splitCoder('publicKey', vecCoder(polyCoder(12), K), 32);
const secretCoder = vecCoder(polyCoder(12), K);
const cipherCoder = splitCoder('ciphertext', vecCoder(polyU, K), polyV);
const seedCoder = splitCoder('seed', 32, 32);
return {
secretCoder,
lengths: {
secretKey: secretCoder.bytesLen,
publicKey: publicCoder.bytesLen,
cipherText: cipherCoder.bytesLen,
},
keygen: (seed) => {
abytes(seed, 32, 'seed');
const seedDst = new Uint8Array(33);
seedDst.set(seed);
// FIPS 203 Algorithm 13 appends the parameter-set byte `k`
// before `G(d || k)`, so expanding the same 32-byte seed
// under a different ML-KEM parameter set yields unrelated keys.
seedDst[32] = K;
const seedHash = HASH512(seedDst);
const [rho, sigma] = seedCoder.decode(seedHash);
const sHat = [];
const tHat = [];
for (let i = 0; i < K; i++)
sHat.push(crystals.NTT.encode(sampleCBD(PRF, sigma, i, ETA1)));
const x = XOF(rho);
for (let i = 0; i < K; i++) {
const e = crystals.NTT.encode(sampleCBD(PRF, sigma, K + i, ETA1));
for (let j = 0; j < K; j++) {
const aji = SampleNTT(x.get(j, i)); // A[i][j], inplace
polyAdd(e, MultiplyNTTs(aji, sHat[j]));
}
tHat.push(e); // t ← A ◦ s + e
}
x.clean();
const res = {
publicKey: publicCoder.encode([tHat, rho]),
secretKey: secretCoder.encode(sHat),
};
cleanBytes(rho, sigma, sHat, tHat, seedDst, seedHash);
return res;
},
encrypt: (publicKey, msg, seed) => {
const [tHat, rho] = publicCoder.decode(publicKey);
const rHat = [];
for (let i = 0; i < K; i++)
rHat.push(crystals.NTT.encode(sampleCBD(PRF, seed, i, ETA1)));
const x = XOF(rho);
const tmp2 = new Uint16Array(N);
const u = [];
for (let i = 0; i < K; i++) {
const e1 = sampleCBD(PRF, seed, K + i, ETA2);
const tmp = new Uint16Array(N);
for (let j = 0; j < K; j++) {
const aij = SampleNTT(x.get(i, j)); // A[j][i], inplace transpose access
polyAdd(tmp, MultiplyNTTs(aij, rHat[j])); // t += aij * rHat[j]
}
polyAdd(e1, crystals.NTT.decode(tmp)); // e1 += tmp
u.push(e1);
polyAdd(tmp2, MultiplyNTTs(tHat[i], rHat[i])); // t2 += tHat[i] * rHat[i]
cleanBytes(tmp);
}
x.clean();
const e2 = sampleCBD(PRF, seed, 2 * K, ETA2);
polyAdd(e2, crystals.NTT.decode(tmp2)); // e2 += tmp2
const v = poly1.decode(msg); // encode plaintext m into polynomial v
polyAdd(v, e2); // v += e2
cleanBytes(tHat, rHat, tmp2, e2);
return cipherCoder.encode([u, v]);
},
decrypt: (cipherText, privateKey) => {
const [u, v] = cipherCoder.decode(cipherText);
const sk = secretCoder.decode(privateKey); // s ← ByteDecode_12(dkPKE)
const tmp = new Uint16Array(N);
// tmp += sk[i] * u[i]
for (let i = 0; i < K; i++)
polyAdd(tmp, MultiplyNTTs(sk[i], crystals.NTT.encode(u[i])));
polySub(v, crystals.NTT.decode(tmp)); // w = v' - tmp
cleanBytes(tmp, sk, u);
return poly1.encode(v);
},
};
};
/**
* Public ML-KEM wrapper over the internal K-PKE subroutine.
* `keygen(seed)` and `encapsulate(publicKey, msg)` are deterministic/test-oriented hooks that map
* more directly to Algorithms 16-17 than to the pure no-input / random-internal Algorithms 19-20.
* decapsulate() tries to follow the Algorithms 18/21 implicit-reject structure as closely as
* practical here by re-encrypting, comparing ciphertexts, returning `Khat` on match or `Kbar` on
* mismatch, and zeroizing the non-returned shared-secret candidate; JS/JIT still provides no
* constant-time guarantees for that path.
*/
function createKyber(opts) {
const rawOpts = opts;
const KPKE = genKPKE(rawOpts);
const { HASH256, HASH512, KDF } = rawOpts;
const { secretCoder: KPKESecretCoder, lengths } = KPKE;
const secretCoder = splitCoder('secretKey', lengths.secretKey, lengths.publicKey, 32, 32);
const msgLen = 32;
const seedLen = 64;
const kemLengths = Object.freeze({
...lengths,
seed: 64,
msg: msgLen,
msgRand: msgLen,
secretKey: secretCoder.bytesLen,
});
return Object.freeze({
info: Object.freeze({ type: 'ml-kem' }),
lengths: kemLengths,
keygen: (seed = randomBytes(seedLen)) => {
abytes(seed, seedLen, 'seed');
const { publicKey, secretKey: sk } = KPKE.keygen(seed.subarray(0, 32));
const publicKeyHash = HASH256(publicKey);
// (dkPKE||ek||H(ek)||z)
const secretKey = secretCoder.encode([sk, publicKey, publicKeyHash, seed.subarray(32)]);
cleanBytes(sk, publicKeyHash);
return {
publicKey: publicKey,
secretKey: secretKey,
};
},
getPublicKey: (secretKey) => {
const [_sk, publicKey, _publicKeyHash, _z] = secretCoder.decode(secretKey);
return Uint8Array.from(publicKey);
},
encapsulate: (publicKey, msg = randomBytes(msgLen)) => {
abytes(publicKey, lengths.publicKey, 'publicKey');
abytes(msg, msgLen, 'message');
// FIPS-203 includes additional verification check for modulus
const eke = publicKey.subarray(0, 384 * opts.K);
// Copy because of inplace encoding
const ek = KPKESecretCoder.encode(KPKESecretCoder.decode(copyBytes(eke)));
// (Modulus check.) Perform the computation ek ← ByteEncode12(ByteDecode12(eke)).
// If ek = ̸ eke, the input is invalid. (See Section 4.2.1.)
if (!equalBytes(ek, eke)) {
cleanBytes(ek);
throw new Error('ML-KEM.encapsulate: wrong publicKey modulus');
}
cleanBytes(ek);
// derive randomness
const kr = HASH512.create().update(msg).update(HASH256(publicKey)).digest();
const cipherText = KPKE.encrypt(publicKey, msg, kr.subarray(32, 64));
cleanBytes(kr.subarray(32));
return {
cipherText: cipherText,
sharedSecret: kr.subarray(0, 32),
};
},
decapsulate: (cipherText, secretKey) => {
abytes(secretKey, secretCoder.bytesLen, 'secretKey'); // 768*k + 96
abytes(cipherText, lengths.cipherText, 'cipherText'); // 32(du*k + dv)
// test ← H(dk[384𝑘 ∶ 768𝑘 + 32])) .
const k768 = secretCoder.bytesLen - 96;
const start = k768 + 32;
const test = HASH256(secretKey.subarray(k768 / 2, start));
// If test ≠ dk[768𝑘 + 32 ∶ 768𝑘 + 64], then input checking has failed.
if (!equalBytes(test, secretKey.subarray(start, start + 32)))
throw new Error('invalid secretKey: hash check failed');
const [sk, publicKey, publicKeyHash, z] = secretCoder.decode(secretKey);
const msg = KPKE.decrypt(cipherText, sk);
// derive randomness, Khat, rHat = G(mHat || h)
const kr = HASH512.create().update(msg).update(publicKeyHash).digest();
const Khat = kr.subarray(0, 32);
// re-encrypt using the derived randomness
const cipherText2 = KPKE.encrypt(publicKey, msg, kr.subarray(32, 64));
// if ciphertexts do not match, “implicitly reject”
const isValid = equalBytes(cipherText, cipherText2);
const Kbar = KDF.create({ dkLen: 32 }).update(z).update(cipherText).digest();
cleanBytes(msg, cipherText2, !isValid ? Khat : Kbar);
return (isValid ? Khat : Kbar);
},
});
}
// FIPS 203's PRF_eta binding: current callers use only 32-byte keys, one-byte nonces,
// and dkLen values {128, 192}; out-of-range nonce numbers still wrap modulo 256 here.
function shakePRF(dkLen, key, nonce) {
return shake256
.create({ dkLen })
.update(key)
.update(new Uint8Array([nonce]))
.digest();
}
// Fixed ML-KEM hash/XOF bindings. `KDF` here is the spec's fixed 32-byte `J` call,
// and swapping any field changes the scheme rather than tuning an internal dependency.
const opts = /* @__PURE__ */ (() => ({
HASH256: sha3_256,
HASH512: sha3_512,
KDF: shake256,
XOF: XOF128,
PRF: shakePRF,
}))();
// Parameter-set instantiation step for the spec's "ML-KEM-x" names; current correctness relies
// on the internal PARAMS rows rather than local validation of arbitrary KEMParam objects.
const mk = (params) => createKyber({
...opts,
...params,
});
/**
* ML-KEM-512: Table 2 row `k=2, η1=3, η2=2, du=10, dv=4`; Table 3 sizes `800/1632/768/32`.
* The ASD lifecycle note here is external policy guidance, not a FIPS 203 requirement.
*/
export const ml_kem512 = /* @__PURE__ */ (() => mk(PARAMS[512]))();
/**
* ML-KEM-768: Table 2 row `k=3, η1=2, η2=2, du=10, dv=4`; Table 3 sizes `1184/2400/1088/32`.
* The ASD lifecycle note here is external policy guidance, not a FIPS 203 requirement.
*/
export const ml_kem768 = /* @__PURE__ */ (() => mk(PARAMS[768]))();
/**
* ML-KEM-1024: Table 2 row `k=4, η1=2, η2=2, du=11, dv=5`; Table 3 sizes `1568/3168/1568/32`.
* The ASD lifecycle note here is external policy guidance, not a FIPS 203 requirement.
*/
export const ml_kem1024 = /* @__PURE__ */ (() => mk(PARAMS[1024]))();
// NOTE: for tests only, don't use. This keeps the exact internal ML-KEM math surfaces available
// without re-implementing them in separate test code.
export const __tests = /* @__PURE__ */ (() => Object.freeze({
Compress_d: (x, d) => {
if (d < 1 || d > 11)
throw new Error(`Compress_d: expected d in [1..11], got ${d}`);
return compress(d).encode(x) & getMask(d);
},
Decompress_d: (y, d) => {
if (d < 1 || d > 11)
throw new Error(`Decompress_d: expected d in [1..11], got ${d}`);
return compress(d).decode(y);
},
ByteEncode_d: (F, d) => {
if (d < 1 || d > 12)
throw new Error(`ByteEncode_d: expected d in [1..12], got ${d}`);
return byteCoder(d).encode(F);
},
ByteDecode_d: (B, d) => {
if (d < 1 || d > 12)
throw new Error(`ByteDecode_d: expected d in [1..12], got ${d}`);
return byteCoder(d).decode(B);
},
NTT: (f) => crystals.NTT.encode(Uint16Array.from(f)),
NTT_inv: (fHat) => crystals.NTT.decode(Uint16Array.from(fHat)),
MultiplyNTTs: (fHat, gHat) => MultiplyNTTs(Uint16Array.from(fHat), Uint16Array.from(gHat)),
SamplePolyCBD: (B, eta) => {
abytes(B, 64 * eta, 'B');
return sampleCBDBytes(B, eta);
},
SampleNTT: (B) => {
abytes(B, 34, 'B');
const xof = XOF128(B.subarray(0, 32));
try {
return SampleNTT(xof.get(B[32], B[33]));
}
finally {
xof.clean();
}
},
}))();
//# sourceMappingURL=ml-kem.js.map