UNPKG

@noble/post-quantum

Version:

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

444 lines 20.1 kB
/** * 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