UNPKG

pqc

Version:

JS Implementation of NIST PQC FIPS Standards

522 lines 22.4 kB
/** * ML-DSA: Module Lattice-based Digital Signature Algorithm from * [FIPS-204](https://csrc.nist.gov/pubs/fips/204/ipd). A.k.a. CRYSTALS-Dilithium. * * Has similar internals to ML-KEM, but their keys and params are different. * Check out [official site](https://www.pq-crystals.org/dilithium/index.shtml), * [repo](https://github.com/pq-crystals/dilithium). */ import { shake256 } from '@noble/hashes/sha3'; import { genCrystals, XOF128, XOF256 } from "../utilities/_crystals.js"; import { cleanBytes, EMPTY, ensureBytes, equalBytes, getMessage, getMessagePrehash, randomBytes, splitCoder, vecCoder, } from "../utilities/utils.js"; // Constants const N = 256; // 2**23 − 2**13 + 1, 23 bits: multiply will be 46. We have enough precision in JS to avoid bigints const Q = 8380417; const ROOT_OF_UNITY = 1753; // f = 256**−1 mod q, pow(256, -1, q) = 8347681 (python3) const F = 8347681; const D = 13; // Dilithium is kinda parametrized over GAMMA2, but everything will break with any other value. const GAMMA2_1 = Math.floor((Q - 1) / 88) | 0; const GAMMA2_2 = Math.floor((Q - 1) / 32) | 0; /** Internal params for different versions of ML-DSA */ // prettier-ignore export const PARAMS = { 2: { K: 4, L: 4, D, GAMMA1: 2 ** 17, GAMMA2: GAMMA2_1, TAU: 39, ETA: 2, OMEGA: 80 }, 3: { K: 6, L: 5, D, GAMMA1: 2 ** 19, GAMMA2: GAMMA2_2, TAU: 49, ETA: 4, OMEGA: 55 }, 5: { K: 8, L: 7, D, GAMMA1: 2 ** 19, GAMMA2: GAMMA2_2, TAU: 60, ETA: 2, OMEGA: 75 }, }; const newPoly = (n) => new Int32Array(n); const { mod, smod, NTT, bitsCoder } = genCrystals({ N, Q, F, ROOT_OF_UNITY, newPoly, isKyber: false, brvBits: 8, }); const id = (n) => n; const polyCoder = (d, compress = id, verify = id) => bitsCoder(d, { encode: (i) => compress(verify(i)), decode: (i) => verify(compress(i)), }); const polyAdd = (a, b) => { for (let i = 0; i < a.length; i++) a[i] = mod(a[i] + b[i]); return a; }; const polySub = (a, b) => { for (let i = 0; i < a.length; i++) a[i] = mod(a[i] - b[i]); return a; }; const polyShiftl = (p) => { for (let i = 0; i < N; i++) p[i] <<= D; return p; }; const polyChknorm = (p, B) => { // Not very sure about this, but FIPS204 doesn't provide any function for that :( for (let i = 0; i < N; i++) if (Math.abs(smod(p[i])) >= B) return true; return false; }; const MultiplyNTTs = (a, b) => { // NOTE: we don't use montgomery reduction in code, since it requires 64 bit ints, // which is not available in JS. mod(a[i] * b[i]) is ok, since Q is 23 bit, // which means a[i] * b[i] is 46 bit, which is safe to use in JS. (number is 53 bits). // Barrett reduction is slower than mod :( const c = newPoly(N); for (let i = 0; i < a.length; i++) c[i] = mod(a[i] * b[i]); return c; }; // Return poly in NTT representation function RejNTTPoly(xof) { // Samples a polynomial ∈ Tq. const r = newPoly(N); // NOTE: we can represent 3xu24 as 4xu32, but it doesn't improve perf :( for (let j = 0; j < N;) { const b = xof(); if (b.length % 3) throw new Error('RejNTTPoly: unaligned block'); for (let i = 0; j < N && i <= b.length - 3; i += 3) { const t = (b[i + 0] | (b[i + 1] << 8) | (b[i + 2] << 16)) & 0x7fffff; // 3 bytes if (t < Q) r[j++] = t; } } return r; } function getDilithium(opts) { const { K, L, GAMMA1, GAMMA2, TAU, ETA, OMEGA } = opts; const { CRH_BYTES, TR_BYTES, C_TILDE_BYTES, XOF128, XOF256 } = opts; if (![2, 4].includes(ETA)) throw new Error('Wrong ETA'); if (![1 << 17, 1 << 19].includes(GAMMA1)) throw new Error('Wrong GAMMA1'); if (![GAMMA2_1, GAMMA2_2].includes(GAMMA2)) throw new Error('Wrong GAMMA2'); const BETA = TAU * ETA; const decompose = (r) => { // Decomposes r into (r1, r0) such that r ≡ r1(2γ2) + r0 mod q. const rPlus = mod(r); const r0 = smod(rPlus, 2 * GAMMA2) | 0; if (rPlus - r0 === Q - 1) return { r1: 0 | 0, r0: (r0 - 1) | 0 }; const r1 = Math.floor((rPlus - r0) / (2 * GAMMA2)) | 0; return { r1, r0 }; // r1 = HighBits, r0 = LowBits }; const HighBits = (r) => decompose(r).r1; const LowBits = (r) => decompose(r).r0; const MakeHint = (z, r) => { // Compute hint bit indicating whether adding z to r alters the high bits of r. // From dilithium code const res0 = z <= GAMMA2 || z > Q - GAMMA2 || (z === Q - GAMMA2 && r === 0) ? 0 : 1; // from FIPS204: // // const r1 = HighBits(r); // // const v1 = HighBits(r + z); // // const res1 = +(r1 !== v1); // But they return different results! However, decompose is same. // So, either there is a bug in Dilithium ref implementation or in FIPS204. // For now, lets use dilithium one, so test vectors can be passed. // See // https://github.com/GiacomoPope/dilithium-py?tab=readme-ov-file#optimising-decomposition-and-making-hints return res0; }; const UseHint = (h, r) => { // Returns the high bits of r adjusted according to hint h const m = Math.floor((Q - 1) / (2 * GAMMA2)); const { r1, r0 } = decompose(r); // 3: if h = 1 and r0 > 0 return (r1 + 1) mod m // 4: if h = 1 and r0 ≤ 0 return (r1 − 1) mod m if (h === 1) return r0 > 0 ? mod(r1 + 1, m) | 0 : mod(r1 - 1, m) | 0; return r1 | 0; }; const Power2Round = (r) => { // Decomposes r into (r1, r0) such that r ≡ r1*(2**d) + r0 mod q. const rPlus = mod(r); const r0 = smod(rPlus, 2 ** D) | 0; return { r1: Math.floor((rPlus - r0) / 2 ** D) | 0, r0 }; }; const hintCoder = { bytesLen: OMEGA + K, encode: (h) => { if (h === false) throw new Error('hint.encode: hint is false'); // should never happen const res = new Uint8Array(OMEGA + K); for (let i = 0, k = 0; i < K; i++) { for (let j = 0; j < N; j++) if (h[i][j] !== 0) res[k++] = j; res[OMEGA + i] = k; } return res; }, decode: (buf) => { const h = []; let k = 0; for (let i = 0; i < K; i++) { const hi = newPoly(N); if (buf[OMEGA + i] < k || buf[OMEGA + i] > OMEGA) return false; for (let j = k; j < buf[OMEGA + i]; j++) { if (j > k && buf[j] <= buf[j - 1]) return false; hi[buf[j]] = 1; } k = buf[OMEGA + i]; h.push(hi); } for (let j = k; j < OMEGA; j++) if (buf[j] !== 0) return false; return h; }, }; const ETACoder = polyCoder(ETA === 2 ? 3 : 4, (i) => ETA - i, (i) => { if (!(-ETA <= i && i <= ETA)) throw new Error(`malformed key s1/s3 ${i} outside of ETA range [${-ETA}, ${ETA}]`); return i; }); const T0Coder = polyCoder(13, (i) => (1 << (D - 1)) - i); const T1Coder = polyCoder(10); // Requires smod. Need to fix! const ZCoder = polyCoder(GAMMA1 === 1 << 17 ? 18 : 20, (i) => smod(GAMMA1 - i)); const W1Coder = polyCoder(GAMMA2 === GAMMA2_1 ? 6 : 4); const W1Vec = vecCoder(W1Coder, K); // Main structures const publicCoder = splitCoder(32, vecCoder(T1Coder, K)); const secretCoder = splitCoder(32, 32, TR_BYTES, vecCoder(ETACoder, L), vecCoder(ETACoder, K), vecCoder(T0Coder, K)); const sigCoder = splitCoder(C_TILDE_BYTES, vecCoder(ZCoder, L), hintCoder); const CoefFromHalfByte = ETA === 2 ? (n) => (n < 15 ? 2 - (n % 5) : false) : (n) => (n < 9 ? 4 - n : false); // Return poly in NTT representation function RejBoundedPoly(xof) { // Samples an element a ∈ Rq with coeffcients in [−η, η] computed via rejection sampling from ρ. const r = newPoly(N); for (let j = 0; j < N;) { const b = xof(); for (let i = 0; j < N && i < b.length; i += 1) { // half byte. Should be superfast with vector instructions. But very slow with js :( const d1 = CoefFromHalfByte(b[i] & 0x0f); const d2 = CoefFromHalfByte((b[i] >> 4) & 0x0f); if (d1 !== false) r[j++] = d1; if (j < N && d2 !== false) r[j++] = d2; } } return r; } const SampleInBall = (seed) => { // Samples a polynomial c ∈ Rq with coeffcients from {−1, 0, 1} and Hamming weight τ const pre = newPoly(N); const s = shake256.create({}).update(seed); const buf = new Uint8Array(shake256.blockLen); s.xofInto(buf); const masks = buf.slice(0, 8); for (let i = N - TAU, pos = 8, maskPos = 0, maskBit = 0; i < N; i++) { let b = i + 1; for (; b > i;) { b = buf[pos++]; if (pos < shake256.blockLen) continue; s.xofInto(buf); pos = 0; } pre[i] = pre[b]; pre[b] = 1 - (((masks[maskPos] >> maskBit++) & 1) << 1); if (maskBit >= 8) { maskPos++; maskBit = 0; } } return pre; }; const polyPowerRound = (p) => { const res0 = newPoly(N); const res1 = newPoly(N); for (let i = 0; i < p.length; i++) { const { r0, r1 } = Power2Round(p[i]); res0[i] = r0; res1[i] = r1; } return { r0: res0, r1: res1 }; }; const polyUseHint = (u, h) => { for (let i = 0; i < N; i++) u[i] = UseHint(h[i], u[i]); return u; }; const polyMakeHint = (a, b) => { const v = newPoly(N); let cnt = 0; for (let i = 0; i < N; i++) { const h = MakeHint(a[i], b[i]); v[i] = h; cnt += h; } return { v, cnt }; }; const signRandBytes = 32; const seedCoder = splitCoder(32, 64, 32); // API & argument positions are exactly as in FIPS204. const internal = { signRandBytes, keygen: (seed) => { // H(𝜉||IntegerToBytes(𝑘, 1)||IntegerToBytes(ℓ, 1), 128) 2: ▷ expand seed const seedDst = new Uint8Array(32 + 2); const randSeed = seed === undefined; if (randSeed) seed = randomBytes(32); ensureBytes(seed, 32); seedDst.set(seed); if (randSeed) seed.fill(0); seedDst[32] = K; seedDst[33] = L; const [rho, rhoPrime, K_] = seedCoder.decode(shake256(seedDst, { dkLen: seedCoder.bytesLen })); const xofPrime = XOF256(rhoPrime); const s1 = []; for (let i = 0; i < L; i++) s1.push(RejBoundedPoly(xofPrime.get(i & 0xff, (i >> 8) & 0xff))); const s2 = []; for (let i = L; i < L + K; i++) s2.push(RejBoundedPoly(xofPrime.get(i & 0xff, (i >> 8) & 0xff))); const s1Hat = s1.map((i) => NTT.encode(i.slice())); const t0 = []; const t1 = []; const xof = XOF128(rho); const t = newPoly(N); for (let i = 0; i < K; i++) { // t ← NTT−1(A*NTT(s1)) + s2 t.fill(0); // don't-reallocate for (let j = 0; j < L; j++) { const aij = RejNTTPoly(xof.get(j, i)); // super slow! polyAdd(t, MultiplyNTTs(aij, s1Hat[j])); } NTT.decode(t); const { r0, r1 } = polyPowerRound(polyAdd(t, s2[i])); // (t1, t0) ← Power2Round(t, d) t0.push(r0); t1.push(r1); } const publicKey = publicCoder.encode([rho, t1]); // pk ← pkEncode(ρ, t1) const tr = shake256(publicKey, { dkLen: TR_BYTES }); // tr ← H(BytesToBits(pk), 512) const secretKey = secretCoder.encode([rho, K_, tr, s1, s2, t0]); // sk ← skEncode(ρ, K,tr, s1, s2, t0) xof.clean(); xofPrime.clean(); // STATS // Kyber512: { calls: 4, xofs: 12 }, Kyber768: { calls: 9, xofs: 27 }, Kyber1024: { calls: 16, xofs: 48 } // DSA44: { calls: 24, xofs: 24 }, DSA65: { calls: 41, xofs: 41 }, DSA87: { calls: 71, xofs: 71 } cleanBytes(rho, rhoPrime, K_, s1, s2, s1Hat, t, t0, t1, tr, seedDst); return { publicKey, secretKey }; }, // NOTE: random is optional. sign: (secretKey, msg, random, externalMu = false) => { // This part can be pre-cached per secretKey, but there is only minor performance improvement, // since we re-use a lot of variables to computation. const [rho, _K, tr, s1, s2, t0] = secretCoder.decode(secretKey); // (ρ, K,tr, s1, s2, t0) ← skDecode(sk) // Cache matrix to avoid re-compute later const A = []; // A ← ExpandA(ρ) const xof = XOF128(rho); for (let i = 0; i < K; i++) { const pv = []; for (let j = 0; j < L; j++) pv.push(RejNTTPoly(xof.get(j, i))); A.push(pv); } xof.clean(); for (let i = 0; i < L; i++) NTT.encode(s1[i]); // sˆ1 ← NTT(s1) for (let i = 0; i < K; i++) { NTT.encode(s2[i]); // sˆ2 ← NTT(s2) NTT.encode(t0[i]); // tˆ0 ← NTT(t0) } // This part is per msg const mu = externalMu ? msg : shake256.create({ dkLen: CRH_BYTES }).update(tr).update(msg).digest(); // 6: µ ← H(tr||M, 512) ▷ Compute message representative µ // Compute private random seed const rnd = random ? random : new Uint8Array(32); ensureBytes(rnd); const rhoprime = shake256 .create({ dkLen: CRH_BYTES }) .update(_K) .update(rnd) .update(mu) .digest(); // ρ′← H(K||rnd||µ, 512) ensureBytes(rhoprime, CRH_BYTES); const x256 = XOF256(rhoprime, ZCoder.bytesLen); // Rejection sampling loop main_loop: for (let kappa = 0;;) { const y = []; // y ← ExpandMask(ρ , κ) for (let i = 0; i < L; i++, kappa++) y.push(ZCoder.decode(x256.get(kappa & 0xff, kappa >> 8)())); const z = y.map((i) => NTT.encode(i.slice())); const w = []; for (let i = 0; i < K; i++) { // w ← NTT−1(A ◦ NTT(y)) const wi = newPoly(N); for (let j = 0; j < L; j++) polyAdd(wi, MultiplyNTTs(A[i][j], z[j])); NTT.decode(wi); w.push(wi); } const w1 = w.map((j) => j.map(HighBits)); // w1 ← HighBits(w) // Commitment hash: c˜ ∈{0, 1 2λ } ← H(µ||w1Encode(w1), 2λ) const cTilde = shake256 .create({ dkLen: C_TILDE_BYTES }) .update(mu) .update(W1Vec.encode(w1)) .digest(); // Verifer’s challenge const cHat = NTT.encode(SampleInBall(cTilde)); // c ← SampleInBall(c˜1); cˆ ← NTT(c) // ⟨⟨cs1⟩⟩ ← NTT−1(cˆ◦ sˆ1) const cs1 = s1.map((i) => MultiplyNTTs(i, cHat)); for (let i = 0; i < L; i++) { polyAdd(NTT.decode(cs1[i]), y[i]); // z ← y + ⟨⟨cs1⟩⟩ if (polyChknorm(cs1[i], GAMMA1 - BETA)) continue main_loop; // ||z||∞ ≥ γ1 − β } // cs1 is now z (▷ Signer’s response) let cnt = 0; const h = []; for (let i = 0; i < K; i++) { const cs2 = NTT.decode(MultiplyNTTs(s2[i], cHat)); // ⟨⟨cs2⟩⟩ ← NTT−1(cˆ◦ sˆ2) const r0 = polySub(w[i], cs2).map(LowBits); // r0 ← LowBits(w − ⟨⟨cs2⟩⟩) if (polyChknorm(r0, GAMMA2 - BETA)) continue main_loop; // ||r0||∞ ≥ γ2 − β const ct0 = NTT.decode(MultiplyNTTs(t0[i], cHat)); // ⟨⟨ct0⟩⟩ ← NTT−1(cˆ◦ tˆ0) if (polyChknorm(ct0, GAMMA2)) continue main_loop; polyAdd(r0, ct0); // ▷ Signer’s hint const hint = polyMakeHint(r0, w1[i]); // h ← MakeHint(−⟨⟨ct0⟩⟩, w− ⟨⟨cs2⟩⟩ + ⟨⟨ct0⟩⟩) h.push(hint.v); cnt += hint.cnt; } if (cnt > OMEGA) continue; // the number of 1’s in h is greater than ω x256.clean(); const res = sigCoder.encode([cTilde, cs1, h]); // σ ← sigEncode(c˜, z mod±q, h) // rho, _K, tr is subarray of secretKey, cannot clean. cleanBytes(cTilde, cs1, h, cHat, w1, w, z, y, rhoprime, mu, s1, s2, t0, ...A); return res; } // @ts-ignore throw new Error('Unreachable code path reached, report this error'); }, verify: (publicKey, msg, sig, externalMu = false) => { // ML-DSA.Verify(pk, M, σ): Verifes a signature σ for a message M. const [rho, t1] = publicCoder.decode(publicKey); // (ρ, t1) ← pkDecode(pk) const tr = shake256(publicKey, { dkLen: TR_BYTES }); // 6: tr ← H(BytesToBits(pk), 512) if (sig.length !== sigCoder.bytesLen) return false; // return false instead of exception const [cTilde, z, h] = sigCoder.decode(sig); // (c˜, z, h) ← sigDecode(σ), ▷ Signer’s commitment hash c ˜, response z and hint if (h === false) return false; // if h = ⊥ then return false for (let i = 0; i < L; i++) if (polyChknorm(z[i], GAMMA1 - BETA)) return false; const mu = externalMu ? msg : shake256.create({ dkLen: CRH_BYTES }).update(tr).update(msg).digest(); // 7: µ ← H(tr||M, 512) // Compute verifer’s challenge from c˜ const c = NTT.encode(SampleInBall(cTilde)); // c ← SampleInBall(c˜1) const zNtt = z.map((i) => i.slice()); // zNtt = NTT(z) for (let i = 0; i < L; i++) NTT.encode(zNtt[i]); const wTick1 = []; const xof = XOF128(rho); for (let i = 0; i < K; i++) { const ct12d = MultiplyNTTs(NTT.encode(polyShiftl(t1[i])), c); //c * t1 * (2**d) const Az = newPoly(N); // // A * z for (let j = 0; j < L; j++) { const aij = RejNTTPoly(xof.get(j, i)); // A[i][j] inplace polyAdd(Az, MultiplyNTTs(aij, zNtt[j])); } // wApprox = A*z - c*t1 * (2**d) const wApprox = NTT.decode(polySub(Az, ct12d)); // Reconstruction of signer’s commitment wTick1.push(polyUseHint(wApprox, h[i])); // w ′ ← UseHint(h, w'approx ) } xof.clean(); // c˜′← H (µ||w1Encode(w′1), 2λ), Hash it; this should match c˜ const c2 = shake256 .create({ dkLen: C_TILDE_BYTES }) .update(mu) .update(W1Vec.encode(wTick1)) .digest(); // Additional checks in FIPS-204: // [[ ||z||∞ < γ1 − β ]] and [[c ˜ = c˜′]] and [[number of 1’s in h is ≤ ω]] for (const t of h) { const sum = t.reduce((acc, i) => acc + i, 0); if (!(sum <= OMEGA)) return false; } for (const t of z) if (polyChknorm(t, GAMMA1 - BETA)) return false; return equalBytes(cTilde, c2); }, }; return { internal, keygen: internal.keygen, signRandBytes: internal.signRandBytes, sign: (secretKey, msg, ctx = EMPTY, random) => { const M = getMessage(msg, ctx); const res = internal.sign(secretKey, M, random); M.fill(0); return res; }, verify: (publicKey, msg, sig, ctx = EMPTY) => { return internal.verify(publicKey, getMessage(msg, ctx), sig); }, prehash: (hashName) => ({ sign: (secretKey, msg, ctx = EMPTY, random) => { const M = getMessagePrehash(hashName, msg, ctx); const res = internal.sign(secretKey, M, random); M.fill(0); return res; }, verify: (publicKey, msg, sig, ctx = EMPTY) => { return internal.verify(publicKey, getMessagePrehash(hashName, msg, ctx), sig); }, }), }; } /** ML-DSA-44 for 128-bit security level. Not recommended after 2030, as per ASD. */ export const ml_dsa44 = /* @__PURE__ */ getDilithium({ ...PARAMS[2], CRH_BYTES: 64, TR_BYTES: 64, C_TILDE_BYTES: 32, XOF128, XOF256, }); /** ML-DSA-65 for 192-bit security level. Not recommended after 2030, as per ASD. */ export const ml_dsa65 = /* @__PURE__ */ getDilithium({ ...PARAMS[3], CRH_BYTES: 64, TR_BYTES: 64, C_TILDE_BYTES: 48, XOF128, XOF256, }); /** ML-DSA-87 for 256-bit security level. OK after 2030, as per ASD. */ export const ml_dsa87 = /* @__PURE__ */ getDilithium({ ...PARAMS[5], CRH_BYTES: 64, TR_BYTES: 64, C_TILDE_BYTES: 64, XOF128, XOF256, });