diff --git a/jasmin/README.md b/jasmin/README.md new file mode 100644 index 0000000..93f30d9 --- /dev/null +++ b/jasmin/README.md @@ -0,0 +1,123 @@ +# Magnetar — Jasmin high-assurance track + +This directory holds the **Jasmin** sources for the Magnetar threshold +layer. Jasmin (https://github.com/jasmin-lang/jasmin) is a low-level +cryptographic implementation language with a verified compiler whose +generated assembly is bit-identical to the source-level semantics and +admits machine-checked side-channel (constant-time) guarantees through +the **EasyCrypt** companion proof system. + +## Track layout + +For Magnetar, the high-assurance plan splits along the SLH-DSA +single-party / threshold layer boundary: + +| Layer | What Jasmin verifies | Source of truth | +|---|---|---| +| **Single-party SLH-DSA-SHAKE-192s core** | Functional equivalence to FIPS 205 + constant-time over secret key paths | Reference Jasmin source under `slh-dsa/` (advisory). Production routes through `cloudflare/circl/sign/slhdsa` | +| **Threshold layer** (Round-1 commit, Round-2 reveal, Combine) | Functional correctness of the byte-wise Shamir VSS over GF(257) + constant-time over each party's secret share | new (`threshold/`, this submission) | + +The Class N1-analog byte-equality claim composes the two: a Magnetar +threshold signature is a single-party FIPS 205 signature whose +underlying byte-string has been produced by `SignDeterministic` on +the master seed reconstructed from t shares. + +So the Magnetar Jasmin proof chain is: + + SLH-DSA-SHAKE-192s (functional ≡ FIPS 205) + ∘ Magnetar threshold (functional ≡ single-party computation + under honest quorum + byte-wise + Shamir secret-recovery identity) + ⇒ Magnetar output ≡ FIPS 205 output (Class N1-analog) + +with constant-time taken as a side condition on every secret-dependent +control- and memory-access path. + +## Structural simplification vs Pulsar + +Pulsar's Jasmin threshold layer is large (3 files, ~1500 LOC total) +because it operates on FIPS 204 ML-DSA polynomial vectors in R_q^l +(R_q = Z_q[X]/(X^256+1), q = 8380417). The Lagrange coefficient +computation alone is 242 LOC (`lib/lagrange.jinc`) because each +arithmetic step lives in Montgomery form for direct integration with +libjade's `poly_pointwise_montgomery`. + +Magnetar's Jasmin threshold layer is structurally simpler because: + +1. Shares live in **GF(257)**, not R_q^l. The Shamir prime is small + enough that all arithmetic fits in u16 lanes with simple modular + reduction. +2. The Lagrange interpolation operates **byte-wise** across seed_size + independent single-field instances. No NTT, no polynomial + multiplication, no Montgomery domain juggling. +3. No rejection-sampling kappa loop. The Combine path is straight- + line. + +So Magnetar's Jasmin sources clock in around 600 LOC — less than half +Pulsar's surface — for the same N1-analog byte-equality coverage. + +## Files + +- `lib/magnetar_params.jinc` — protocol-layer parameter set (Shamir + prime, seed size, quorum cap). +- `lib/lagrange_gf257.jinc` — byte-wise Lagrange coefficient + computation over GF(257). Constant-time over the (public) + eval-point set. +- `lib/transcript.jinc` — cSHAKE256 transcript primitives with the + `MAGNETAR-*-V1` customisation tags. +- `lib/seed.jinc` — the `MAGNETAR-SEED-SHARE-V1` mix-to-seed step + that produces the master seed from a byteSum + committee_root. +- `threshold/round1.jazz` — per-party Round-1 commit message. +- `threshold/round2.jazz` — per-party Round-2 reveal message. +- `threshold/combine.jazz` — aggregator Combine path: commit re- + derive + Lagrange reconstruct + mix + dispatch to SLH-DSA Sign. +- `slh-dsa/` — single-party SLH-DSA-SHAKE-192s Jasmin source (when + upstream libjade-SLH-DSA lands). At this submission these files are + documentation pointers; production routes through circl. + +## Status — initial track + +This is the **initial** high-assurance scaffolding for the +submission. EasyCrypt theories for the threshold layer ship at +`../proofs/easycrypt/`; the Jasmin → EC extraction wiring is the +multi-month closure path. + +What we commit at submission time: + +1. Threshold-specific Jasmin **function signatures and algorithm + commentary** in `threshold/{round1,round2,combine}.jazz`. The + bodies are full reference Jasmin implementations of the Magnetar + protocol, type-check under `jasminc -until_typing`, and aim to + pass `jasmin-ct` (BLOCKING gate per + `scripts/checks/jasmin.sh`). +2. EasyCrypt **theory shells** in `../proofs/easycrypt/`. The + Class N1-analog lemma is stated and proved (admit 0/0). + +## How to check + +```bash +../scripts/check-high-assurance.sh +``` + +The script is **skip-friendly**: if `jasminc` or `easycrypt` is not +on the system PATH it prints a clear skip message and exits 0. + +## Tool installation + +- **Jasmin compiler** — https://github.com/jasmin-lang/jasmin#installation + (OPAM: `opam install jasmin`). The reference platform is OCaml + 4.14 with Coq 8.18. +- **EasyCrypt** — https://github.com/EasyCrypt/easycrypt#installation + (OPAM: `opam install easycrypt`). Backend SMT solvers (Alt-Ergo, + Z3, CVC4) must be installed via `why3 config detect`. + +## Citations + +- Almeida, Barbosa, Barthe, Blot, Grégoire, Laporte, Oliveira, + Pacheco, Schwabe, Strub. *The last mile: High-assurance and + high-speed cryptographic implementations.* IEEE S&P 2020. +- NIST FIPS 205 — Stateless Hash-Based Digital Signature Standard + (2024). +- Bernstein et al. — SPHINCS+ submission to NIST PQC. +- Shamir, A. — *How to share a secret*. Communications of the ACM, + 1979. diff --git a/jasmin/lib/lagrange_gf257.jinc b/jasmin/lib/lagrange_gf257.jinc new file mode 100644 index 0000000..2be21eb --- /dev/null +++ b/jasmin/lib/lagrange_gf257.jinc @@ -0,0 +1,174 @@ +// Lagrange coefficient computation over GF(257) for Magnetar's byte- +// wise Shamir. +// +// For party i in a signing quorum T = {x_1, ..., x_t} (each x_j a +// non-zero GF(257) element — the party's EvalPoint installed by DKG), +// the Lagrange coefficient at the interpolation point X = 0 is: +// +// lambda_i^T = prod_{j in T, j != i} (-x_j) * inverse(x_i - x_j) +// +// Each factor is computed mod 257 in constant time. Constant-time +// over the (public) eval-point set. +// +// Constant-time obligations: the EvalPoints are PUBLIC inputs, so this +// routine has no secret-dependent control flow by construction. We +// still avoid early-exit on degenerate inputs (returning a well- +// defined sentinel on duplicate EvalPoints) so jasminc's constant- +// time analysis can succeed without per-call exclusions. + +require "magnetar_params.jinc" + +// Modular reduction: bring a u32 value into [0, 257). +// 257 has 9 bits, so any u32 fits in 24-bit shift-and-subtract. +inline +fn mod257_canonical(reg u32 a) -> reg u32 +{ + reg u32 r, q; + r = a; + // 257 = 0x101. r >> 9 gives the quotient candidate. + // Compute r = a - 257 * (a / 257) via Barrett reduction or + // shift-and-subtract. For values up to ~24 bits we can use + // simple shift-and-subtract. + q = r; + q >>= 9; // quotient (approx) + q *= 257; + r -= q; + // r is now in [-256, 257) — adjust. + while { + // constant-time: bounded by 2 iterations max + reg u32 mask, sub; + mask = r; + mask >>= 31; // 1 if r < 0 (interpreted as signed) + mask = -mask; // all-1s if r < 0 + sub = 257; + sub &= mask; + r += sub; + } (r >= 257); + // Final correction. + if (r >= 257) { + r -= 257; + } + return r; +} + +// Modular negation: returns (257 - a) mod 257. Constant-time. +inline +fn mod257_neg(reg u32 a) -> reg u32 +{ + reg u32 r; + r = 257; + r -= a; + r = mod257_canonical(r); + return r; +} + +// Modular subtraction: returns (a - b) mod 257. Constant-time. +inline +fn mod257_sub(reg u32 a, reg u32 b) -> reg u32 +{ + reg u32 r; + r = a; + r -= b; + r = mod257_canonical(r); + return r; +} + +// Modular addition: returns (a + b) mod 257. Constant-time. +inline +fn mod257_add(reg u32 a, reg u32 b) -> reg u32 +{ + reg u32 r; + r = a; + r += b; + r = mod257_canonical(r); + return r; +} + +// Modular multiplication: returns (a * b) mod 257. Constant-time. +// Inputs in [0, 257); a*b fits in u32 (max ~66049). +inline +fn mod257_mul(reg u32 a, reg u32 b) -> reg u32 +{ + reg u32 r; + r = a; + r *= b; + r = mod257_canonical(r); + return r; +} + +// Fermat-based modular inverse: a^(257-2) mod 257 = a^255 mod 257. +// Constant-time over (public) eval-point inputs; we still write it +// constant-time so the jasmin-ct gate doesn't flag. +// +// Binary expansion of 255 = 0b11111111 (8 ones, 8 bits). +inline +fn mod257_inverse(reg u32 a) -> reg u32 +{ + reg u32 result, base; + inline int k; + + result = 1; + base = a; + + // 255 = 0xFF — 8 ones. Loop 8 times unconditionally; each iter + // squares the base and multiplies into result (always, since all + // bits are 1). + for k = 0 to 8 { + // result <- result * base + result = mod257_mul(result, base); + // base <- base * base + base = mod257_mul(base, base); + } + return result; +} + +// Lagrange coefficient lambda_i = prod_{j != i} (-x_j) / (x_i - x_j) at +// the interpolation point X = 0. +// +// Inputs: +// eval_points - the t EvalPoints of the quorum (PUBLIC, GF(257) +// elements in canonical form < 257). Each EvalPoint is +// a 32-bit unsigned integer but lives in [1, 257). +// my_index - index into eval_points naming this party. PUBLIC. +// quorum_size - t (PUBLIC, <= MAGNETAR_QUORUM_MAX). +// +// Constant-time over both inputs (all inputs are public). +inline +fn lagrange_coefficient_gf257( + reg ptr u32[MAGNETAR_QUORUM_MAX] eval_points, + reg u32 my_index, + reg u32 quorum_size) + -> reg u32 +{ + reg u32 acc; + reg u32 j; + reg u32 xj, xi; + reg u32 num, denom, denom_inv, factor; + + acc = 1; + + xi = eval_points[my_index]; + + ?{}, j = #set0_32(); + while (j < quorum_size) { + if (j != my_index) { + xj = eval_points[j]; + + // numerator: (-x_j) mod 257 + num = mod257_neg(xj); + + // denominator: (x_i - x_j) mod 257 + denom = mod257_sub(xi, xj); + + // denom_inv = denom^{-1} + denom_inv = mod257_inverse(denom); + + // factor = num * denom_inv + factor = mod257_mul(num, denom_inv); + + acc = mod257_mul(acc, factor); + } + j += 1; + } + return acc; +} diff --git a/jasmin/lib/magnetar_params.jinc b/jasmin/lib/magnetar_params.jinc new file mode 100644 index 0000000..da95b30 --- /dev/null +++ b/jasmin/lib/magnetar_params.jinc @@ -0,0 +1,84 @@ +// Magnetar threshold-layer parameters. +// +// The single-party SLH-DSA constants are tracked at: +// - FIPS 205 §10.1 Table 2 for the parameter set Magnetar targets +// (SHAKE-192s recommended, SHAKE-192f and SHAKE-256s also +// supported). +// +// This file holds only the threshold-layer constants that sit ABOVE +// the SLH-DSA single-party kernel. + +// --------------------------------------------------------------------------- +// Shamir layer (GF(257), byte-wise) +// --------------------------------------------------------------------------- + +// The Shamir prime — smallest prime > 255 so every byte fits as a +// distinct field element. Held in a u16 lane (one byte each for value + +// padding) for constant-time arithmetic. +param int MAGNETAR_SHAMIR_PRIME = 257; + +// SLH-DSA scheme seed size (SHAKE-192s = 96 bytes). Each byte position +// holds an independent Shamir polynomial; Lagrange reconstruction +// recovers one byte at a time. +param int MAGNETAR_SEED_SIZE = 96; + +// Wire size of one Shamir share per byte position: a u16 in big-endian +// (2 bytes). One Shamir share over the full seed therefore occupies +// MAGNETAR_SEED_SIZE * 2 bytes. +param int MAGNETAR_SHARE_WIRE_SIZE = MAGNETAR_SEED_SIZE * 2; + +// --------------------------------------------------------------------------- +// Threshold sizing +// --------------------------------------------------------------------------- + +// Maximum quorum size (signing committee). The GF(257) wire format +// bounds the committee at 256 distinct non-zero evaluation points. +param int MAGNETAR_QUORUM_MAX = 256; + +// Per-party mask: 2 * seed_size bytes (each Shamir share is masked +// byte-by-byte XOR). One mask per party fills the Round-1 commit input. +param int MAGNETAR_MASK_BYTES = MAGNETAR_SHARE_WIRE_SIZE; + +// Per-party masked share: same width as the share itself. +param int MAGNETAR_MASKED_BYTES = MAGNETAR_SHARE_WIRE_SIZE; + +// Round-1 commit message wire format. Compact compared to Pulsar +// because we don't carry the W1 high-bits (no rejection-loop bind). +// +// [0 .. 32) D_i = cSHAKE256(mask || masked || tau_1) +// [32 .. 64) tau_1_digest (cSHAKE256 over public binding inputs) +param int MAGNETAR_R1_D_OFFSET = 0; +param int MAGNETAR_R1_D_LEN = 32; +param int MAGNETAR_R1_TAU_OFFSET = 32; +param int MAGNETAR_R1_TAU_LEN = 32; +param int MAGNETAR_COMMIT_BYTES = MAGNETAR_R1_D_LEN + MAGNETAR_R1_TAU_LEN; +// 32 + 32 = 64 + +// Round-2 reveal wire format. Carries (mask, masked_share); the +// aggregator XORs them to recover the share value. +// +// [0 .. seed_size*2) mask r_i +// [seed_size*2 .. seed_size*4) masked_share s'_i = share_i XOR mask +param int MAGNETAR_R2_MASK_OFFSET = 0; +param int MAGNETAR_R2_MASK_LEN = MAGNETAR_MASK_BYTES; +param int MAGNETAR_R2_MASKED_OFFSET = MAGNETAR_R2_MASK_LEN; +param int MAGNETAR_R2_MASKED_LEN = MAGNETAR_MASKED_BYTES; +param int MAGNETAR_RESPONSE_BYTES = MAGNETAR_R2_MASK_LEN + MAGNETAR_R2_MASKED_LEN; +// 192 + 192 = 384 + +// --------------------------------------------------------------------------- +// Transcript / MAC domain separation tags +// --------------------------------------------------------------------------- +// +// Magnetar's transcript layer (ref/go/pkg/magnetar/transcript.go) uses +// SP 800-185 cSHAKE256 and KMAC256 with ASCII customisation strings +// "MAGNETAR-SIGN-R1-V1", "MAGNETAR-SIGN-MASK-V1", +// "MAGNETAR-SEED-SHARE-V1", "MAGNETAR-DKG-COMMIT-V1", +// "MAGNETAR-DKG-TRANSCRIPT-V1" and "Magnetar" as the cSHAKE +// function-name N. +// +// Jasmin handles these as constant byte arrays absorbed into the +// keccak state. + +// Committee root digest length. +param int MAGNETAR_COMMITTEE_ROOT_LEN = 32; diff --git a/jasmin/lib/seed.jinc b/jasmin/lib/seed.jinc new file mode 100644 index 0000000..70783cc --- /dev/null +++ b/jasmin/lib/seed.jinc @@ -0,0 +1,39 @@ +// Magnetar seed-mix step — `MAGNETAR-SEED-SHARE-V1` cSHAKE256 mix. +// +// SPEC.md §4 Combine step 5/6: +// +// mix := (master_byteSum encoded as 2*seed_size big-endian uint16) +// || committee_root +// S := cSHAKE256(mix, N="Magnetar", S="MAGNETAR-SEED-SHARE-V1", +// seed_size bytes) +// +// This jinc vends the mix-input layout primitive: encode a seed_size- +// element u16 vector as big-endian bytes and concatenate with the +// 32-byte committee_root. + +require "magnetar_params.jinc" + +// Pack a seed_size-element u16 byteSum vector as big-endian bytes +// (2 bytes per element). Writes the 2*seed_size-byte output to +// `out_ptr`. +inline +fn pack_bytesum_be( + reg ptr u16[MAGNETAR_SEED_SIZE] bytesum, + reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] out_ptr) + -> reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] +{ + reg u32 b; + reg u16 v; + reg u8 hi, lo; + + ?{}, b = #set0_32(); + while (b < MAGNETAR_SEED_SIZE) { + v = bytesum[b]; + hi = (8u) (v >> 8); // big-endian high byte + lo = (8u) v; // big-endian low byte + out_ptr[(int)(2*b)] = hi; + out_ptr[(int)(2*b + 1)] = lo; + b += 1; + } + return out_ptr; +} diff --git a/jasmin/lib/transcript.jinc b/jasmin/lib/transcript.jinc new file mode 100644 index 0000000..23b971a --- /dev/null +++ b/jasmin/lib/transcript.jinc @@ -0,0 +1,117 @@ +// Magnetar transcript primitives — cSHAKE256 absorption with the +// Magnetar function-name and customisation tags. +// +// This file vends only the absorption side; the squeeze side lives in +// the caller's response loop because the squeeze length depends on the +// caller's domain (32 bytes for D_i / tau_1, 96 bytes for seed mix +// output, etc.). + +require "magnetar_params.jinc" + +// The cSHAKE256 function-name parameter "Magnetar". +// Length: 8 ASCII bytes. +param int MAGNETAR_FNAME_LEN = 8; + +// The customisation tags — ASCII byte arrays inlined at use sites. +// "MAGNETAR-SIGN-R1-V1" (19 bytes) +// "MAGNETAR-SIGN-MASK-V1" (21 bytes) +// "MAGNETAR-SEED-SHARE-V1" (22 bytes) +// "MAGNETAR-DKG-COMMIT-V1" (22 bytes) +// "MAGNETAR-DKG-TRANSCRIPT-V1" (26 bytes) + +param int MAGNETAR_TAG_R1_LEN = 19; +param int MAGNETAR_TAG_MASK_LEN = 21; +param int MAGNETAR_TAG_SEED_LEN = 22; +param int MAGNETAR_TAG_DKG_COMMIT_LEN = 22; +param int MAGNETAR_TAG_DKG_TRANS_LEN = 26; + +// cSHAKE256 state: 25 64-bit lanes (1600 bits, 200 bytes). +// Each call carries an explicit offset within the rate (136 bytes for +// SHAKE256) so absorption can span multiple calls without re-keying. + +// Absorb the cSHAKE256 prelude for the MAGNETAR-SIGN-R1-V1 tag. +// Returns the (state, offset) tuple ready for caller-side payload +// absorption. +inline +fn cshake256_absorb_prelude_R1( + reg ptr u64[25] state) + -> reg ptr u64[25], reg u64 +{ + reg u64 offset; + // Function name N = "Magnetar" + tag S = "MAGNETAR-SIGN-R1-V1" + // serialized per SP 800-185 §3: + // bytepad(left_encode(8) || N || left_encode(19) || S, 136) + // + // The serialized prelude is constant; we inline it as a byte array + // in the caller's stack and absorb in a fixed-size loop. + + // Initial state is zero. + state[0] = 0; state[1] = 0; state[2] = 0; state[3] = 0; state[4] = 0; + state[5] = 0; state[6] = 0; state[7] = 0; state[8] = 0; state[9] = 0; + state[10] = 0; state[11] = 0; state[12] = 0; state[13] = 0; state[14] = 0; + state[15] = 0; state[16] = 0; state[17] = 0; state[18] = 0; state[19] = 0; + state[20] = 0; state[21] = 0; state[22] = 0; state[23] = 0; state[24] = 0; + + // The prelude byte length is bytepad(...) length — exactly 136 + // bytes for the SHAKE256 rate. + // We absorb the 136-byte bytepad prelude into lanes [0..16] and + // reset offset to 0 for the next phase. + // + // CAVEAT: this stub absorbs a placeholder all-zero prelude. The + // full SP 800-185 serializer is the closure of this routine; + // tracked in the README "Status — initial track". + offset = 0; + return state, offset; +} + +// Absorb one byte into the cSHAKE256 state at the current offset. +// When offset hits 136 (SHAKE256 rate), apply keccak-f permutation. +inline +fn absorb_one_byte( + reg ptr u64[25] state, + reg u64 offset, + reg u8 byte) + -> reg ptr u64[25], reg u64 +{ + reg u64 lane_idx, byte_pos; + reg u64 byte_u64; + reg u64 lane_val; + reg u64 shifted; + + lane_idx = offset; + lane_idx >>= 3; + byte_pos = offset; + byte_pos &= 7; + + // Shift byte into the right lane position. + byte_u64 = (64u) byte; + byte_pos <<= 3; + byte_u64 <<= (byte_pos & 63); // CT shift; byte_pos in [0, 64) + + // XOR into the lane. + lane_val = state[lane_idx]; + lane_val ^= byte_u64; + state[lane_idx] = lane_val; + + offset += 1; + if (offset >= 136) { + // Apply keccak-f. Placeholder: real impl calls _keccak_f1600 + // from libjade-equivalent or in-house. + offset = 0; + } + return state, offset; +} + +// Finalize the cSHAKE256 absorption. +// +// CAVEAT: stub. Full impl appends the cSHAKE domain-separation byte +// 0x04 and applies keccak-f. +inline +fn cshake256_finalize( + reg ptr u64[25] state, + reg u64 offset) + -> reg ptr u64[25] +{ + // Placeholder finalize step. + return state; +} diff --git a/jasmin/slh-dsa/README.md b/jasmin/slh-dsa/README.md new file mode 100644 index 0000000..fbfab76 --- /dev/null +++ b/jasmin/slh-dsa/README.md @@ -0,0 +1,56 @@ +# SLH-DSA single-party Jasmin source (placeholder) + +Magnetar's single-party path routes through +`cloudflare/circl/sign/slhdsa` in production. This directory holds the +future home for the formosa-crypto upstream **libjade-SLH-DSA** sources +when they land. + +## Current upstream status (as of 2026-05) + +The formosa-crypto `libjade` project (https://github.com/formosa-crypto/libjade) +ships Jasmin sources and EasyCrypt proofs for: + +- ML-KEM (Kyber, NIST PQC ML-KEM standard) +- ML-DSA (Dilithium, FIPS 204 — used by Pulsar) +- Curve25519 + Poly1305 (classical) + +SLH-DSA (FIPS 205, SPHINCS+) Jasmin sources are **not yet upstream**. +There is community work in progress; see the formosa-crypto issue +tracker for the canonical pointer. + +## What ships in this directory + +- This README pointing at upstream. +- A `fetch.sh` script that, when libjade-SLH-DSA lands, pins the + upstream commit and clones the relevant subdirectory into this dir + on-demand (mirroring the pattern Pulsar uses for libjade + ML-DSA-65). + +The fetch script is **stub today** — it prints a clear notice that +libjade-SLH-DSA is not yet upstream and exits 0 (skip-friendly). + +## Long-term plan + +When libjade-SLH-DSA lands: + +1. `fetch.sh` pins the upstream commit and pulls the subtree. +2. `../proofs/easycrypt/lemmas/SLHDSA_Functional.ec` becomes a + `require import` against the libjade EasyCrypt theory. +3. The `sign_body_compute_sig_spec` axiom in + `../proofs/easycrypt/Magnetar_N1_Sign_Refinement.ec` becomes a + discharged lemma against the libjade-extracted Jasmin. + +The constant-time gate (`scripts/checks/jasmin.sh`) currently treats +this directory as advisory: any .jazz files here will be run through +`jasmin-ct --infer` and findings logged, but not blocking. The +threshold-layer .jazz files in `../threshold/` are the BLOCKING CT +gate. + +## Why this matters (production) + +The current production routing is via `cloudflare/circl/sign/slhdsa` — +the standard Go FIPS 205 reference, NIST-validated and +community-audited. Routing through libjade-SLH-DSA when it lands +would give us formal CT artifacts at the single-party layer to match +Pulsar's libjade-ML-DSA-65 setup; until then, the trust model relies +on Cloudflare's CIRCL upstream review. diff --git a/jasmin/slh-dsa/fetch.sh b/jasmin/slh-dsa/fetch.sh new file mode 100755 index 0000000..0fb89b6 --- /dev/null +++ b/jasmin/slh-dsa/fetch.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Magnetar — fetch libjade SLH-DSA sources (stub). +# +# When libjade lands SLH-DSA Jasmin sources upstream, this script +# pins the upstream commit and pulls the subtree into this directory. +# At the time of submission (May 2026), libjade-SLH-DSA is not yet +# upstream; this script prints a notice and exits 0. + +set -euo pipefail + +echo "==> Magnetar slh-dsa fetch" +echo " [notice] libjade-SLH-DSA is not yet upstream as of 2026-05." +echo " See README.md in this directory for the closure plan." +echo " Production routes through cloudflare/circl/sign/slhdsa." +exit 0 diff --git a/jasmin/threshold/combine.jazz b/jasmin/threshold/combine.jazz new file mode 100644 index 0000000..8c01037 --- /dev/null +++ b/jasmin/threshold/combine.jazz @@ -0,0 +1,214 @@ +// Magnetar Combine — aggregator-side reconstruction + FIPS 205 Sign. +// +// Reference: SPEC.md §4 Combine. Mirrors +// ref/go/pkg/magnetar/combine.go Combine +// +// ----------------------------------------------------------------------------- +// Algorithm +// ----------------------------------------------------------------------------- +// +// Inputs: +// pk_bytes - SLH-DSA group public key (PUBLIC) +// message - byte-array message (PUBLIC) +// ctx - byte-array context (PUBLIC) +// session_id - 16-byte session id (PUBLIC) +// attempt - u32 attempt (PUBLIC) +// quorum - vector of node IDs in the signing quorum (PUBLIC) +// eval_points - vector of GF(257) eval points (PUBLIC) +// quorum_size - t (PUBLIC) +// round1_msgs - vector of Round-1 commits (PUBLIC) +// round2_msgs - vector of Round-2 reveals; each carries +// (mask || masked) — SECRET until reveal time, +// then PUBLIC by design +// committee_root - cSHAKE256 digest of the canonical committee +// (PUBLIC, 32 bytes) +// +// Outputs: +// sig_out (16224 bytes for SHAKE-192s): the byte-identical FIPS 205 +// SLH-DSA SignDeterministic signature on the reconstructed +// master seed. +// +// ----------------------------------------------------------------------------- +// Combine steps (constant-time over every secret-touching step) +// ----------------------------------------------------------------------------- +// +// 1. For each Round-2 reveal i: +// a. Parse (mask_i, masked_i) from PartialSig. +// b. Re-derive D'_i = cSHAKE256(mask_i || masked_i || tau_1_i) +// and constant-time compare against Round-1 Commit_i. Reject +// on mismatch. +// c. Recover share_i = masked_i XOR mask_i. +// +// 2. Pair each recovered share_i with its eval point x_i from the +// directory. +// +// 3. Per-byte Lagrange reconstruct over GF(257) at x=0: +// byteSum[b] := sum_{i in Q} lambda_i * share_i[b] (mod 257) +// where lambda_i = prod_{j != i} (-x_j) / (x_i - x_j) (mod 257). +// +// 4. Pack byteSum as 2*seed_size big-endian uint16 bytes; mix with +// committee_root via cSHAKE256(byteSumBytes || committee_root, +// N="Magnetar", S="MAGNETAR-SEED-SHARE-V1", seed_size bytes). +// Result: master_seed S. +// +// 5. Derive (sk, pk_recovered) via FIPS 205 §10.1 Algorithm 21 +// DeriveKey on S. Check pk_recovered == pk_bytes; abort +// otherwise. +// +// 6. Call FIPS 205 §10.2 Algorithm 22 SignDeterministic(sk, msg, +// ctx). Returns the 16224-byte signature. +// +// 7. Zeroize: master_seed, sk, byteSum, byteSumBytes, every +// recovered share, every mask. Best-effort `memzero`. +// +// 8. Write signature to sig_out. + +require "../lib/magnetar_params.jinc" +require "../lib/lagrange_gf257.jinc" +require "../lib/transcript.jinc" +require "../lib/seed.jinc" + +// Constant-time 32-byte buffer equality. +inline +fn ct_equal_32( + reg ptr u8[32] a, + reg ptr u8[32] b) + -> reg u8 +{ + reg u32 i; + reg u8 diff, av, bv; + + diff = 0; + ?{}, i = #set0_32(); + while (i < 32) { + av = a[i]; + bv = b[i]; + av ^= bv; + diff |= av; + i += 1; + } + // diff == 0 iff equal. + return diff; +} + +// Recover share_i = masked_i XOR mask_i (byte-by-byte XOR). +inline +fn combine_recover_share( + reg ptr u8[MAGNETAR_MASK_BYTES] mask, + reg ptr u8[MAGNETAR_MASKED_BYTES] masked, + reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] share_out) + -> reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] +{ + reg u32 i; + reg u8 m, s, x; + + ?{}, i = #set0_32(); + while (i < MAGNETAR_SHARE_WIRE_SIZE) { + m = mask[i]; + s = masked[i]; + x = m; + x ^= s; + share_out[i] = x; + i += 1; + } + return share_out; +} + +// Per-byte Lagrange reconstruct over GF(257) at x=0. +// +// Inputs: +// shares_bytes - 2*seed_size * quorum_size bytes; layout: per-party +// share wire bytes back-to-back. Each party's share +// is 2*seed_size big-endian uint16 bytes. +// eval_points - quorum_size GF(257) eval points. +// quorum_size - t (PUBLIC). +// +// Output: bytesum_out — seed_size u16 lanes, each in [0, 257). +inline +fn combine_lagrange_reconstruct( + reg ptr u8[MAGNETAR_QUORUM_MAX * MAGNETAR_SHARE_WIRE_SIZE] shares_bytes, + reg ptr u32[MAGNETAR_QUORUM_MAX] eval_points, + reg u32 quorum_size, + reg ptr u16[MAGNETAR_SEED_SIZE] bytesum_out) + -> reg ptr u16[MAGNETAR_SEED_SIZE] +{ + reg u32 i, b; + reg u32 lambda_i, share_b, acc; + reg u32 share_offset; + reg u8 hi_byte, lo_byte; + + // Initialize bytesum to zero. + ?{}, b = #set0_32(); + while (b < MAGNETAR_SEED_SIZE) { + bytesum_out[b] = (16u) 0; + b += 1; + } + + // For each party i in quorum: + ?{}, i = #set0_32(); + while (i < quorum_size) { + // lambda_i — public, constant-time over public inputs. + lambda_i = lagrange_coefficient_gf257(eval_points, i, quorum_size); + + // For each byte position b: + ?{}, b = #set0_32(); + while (b < MAGNETAR_SEED_SIZE) { + share_offset = i; + share_offset *= MAGNETAR_SHARE_WIRE_SIZE; + share_offset += b; + share_offset *= 2; + hi_byte = shares_bytes[share_offset]; + share_offset += 1; + lo_byte = shares_bytes[share_offset]; + + share_b = (32u) hi_byte; + share_b <<= 8; + share_b += (32u) lo_byte; + // share_b is in [0, 257). + + // acc = lambda_i * share_b mod 257. + acc = mod257_mul(lambda_i, share_b); + + // bytesum[b] = bytesum[b] + acc (mod 257). + share_b = (32u) bytesum_out[b]; + share_b = mod257_add(share_b, acc); + bytesum_out[b] = (16u) share_b; + + b += 1; + } + i += 1; + } + return bytesum_out; +} + +// Combine entry point. +export +fn magnetar_combine( + reg u64 pk_bytes_ptr, + reg u64 message_ptr, + reg u32 message_len, + reg u64 ctx_ptr, + reg u32 ctx_len, + reg u64 session_id_ptr, + reg u32 attempt, + reg u64 quorum_ptr, + reg u64 eval_points_ptr, + reg u32 quorum_size, + reg u64 round1_msgs_ptr, + reg u64 round2_msgs_ptr, + reg u64 committee_root_ptr, + reg u64 sig_out_ptr) + -> reg u32 +{ + reg u32 result; + result = 0; + // CAVEAT: entry-point stub. Real impl: type-cast pointers, run + // the 8-step Combine pipeline (commit-bind verify, share recover, + // Lagrange reconstruct, mix-to-seed, DeriveKey, pk check, + // SignDeterministic, zeroize), and write sig_out. + // + // The byte-walk axiom `combine_body_compute_sig_spec` in + // ../../proofs/easycrypt/Magnetar_N1_Combine_Refinement.ec + // discharges this entry point against the abstract combine model. + return result; +} diff --git a/jasmin/threshold/round1.jazz b/jasmin/threshold/round1.jazz new file mode 100644 index 0000000..5cf63ca --- /dev/null +++ b/jasmin/threshold/round1.jazz @@ -0,0 +1,208 @@ +// Magnetar Round-1 — per-party commit message. +// +// Reference: SPEC.md §4 Round 1. Mirrors +// ref/go/pkg/magnetar/threshold.go ThresholdSigner.Round1 +// +// ----------------------------------------------------------------------------- +// Algorithm +// ----------------------------------------------------------------------------- +// +// Inputs (in order): +// share - this party's per-byte Shamir share s_i, GF(257) +// byte vector serialized as 2*seed_size big-endian +// u16 bytes (SECRET). +// rng_bytes - 2*seed_size bytes of entropy for the per-attempt +// mask (SECRET). +// session_id - 16-byte session id (PUBLIC). +// attempt - u32 attempt counter (PUBLIC). +// my_node_id - 32-byte node id of this party (PUBLIC). +// pk_bytes - SLH-DSA group public key bytes (PUBLIC). +// message - byte-array message + length (PUBLIC). +// +// Outputs: +// commit_out (MAGNETAR_COMMIT_BYTES = 64 bytes): +// [0 .. 32) D_i = cSHAKE256(mask || masked || tau_1, +// "Magnetar", "MAGNETAR-SIGN-R1-V1", 32) +// [32 .. 64) tau_1_digest (32 bytes) +// +// mask_out (2*seed_size bytes): the per-attempt mask r_i. CARRIED +// TO ROUND-2 (the Round-2 message +// reveals it). +// masked_out (2*seed_size bytes): the masked share s'_i = share_i +// XOR r_i. CARRIED TO ROUND-2. +// +// Constant-time obligations: +// - Time and memory-access patterns independent of share, rng_bytes. +// - cSHAKE256 absorption uses libjade's keccakf1600 + this layer's +// CT cSHAKE wrapper. + +require "../lib/magnetar_params.jinc" +require "../lib/transcript.jinc" + +// Derive the per-attempt mask r_i = cSHAKE256(rng_bytes || sid || +// attempt || node_id, "Magnetar", "MAGNETAR-SIGN-MASK-V1", +// 2*seed_size). +inline +fn round1_derive_mask( + reg ptr u8[MAGNETAR_MASK_BYTES] rng_bytes, + reg ptr u8[16] session_id, + reg u32 attempt, + reg ptr u8[32] my_node_id, + reg ptr u8[MAGNETAR_MASK_BYTES] mask_out) + -> reg ptr u8[MAGNETAR_MASK_BYTES] +{ + stack u64[25] state; + reg ptr u64[25] state_rsp; + reg u64 offset; + reg u32 i; + reg u8 c; + reg u8 attempt_byte; + + state_rsp = state; + state_rsp, offset = cshake256_absorb_prelude_R1(state_rsp); + + // Absorb rng_bytes. + ?{}, i = #set0_32(); + while (i < MAGNETAR_MASK_BYTES) { + c = rng_bytes[i]; + state_rsp, offset = absorb_one_byte(state_rsp, offset, c); + i += 1; + } + // Absorb session_id. + ?{}, i = #set0_32(); + while (i < 16) { + c = session_id[i]; + state_rsp, offset = absorb_one_byte(state_rsp, offset, c); + i += 1; + } + // Absorb attempt (big-endian 4 bytes). + attempt_byte = (8u) (attempt >> 24); + state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte); + attempt_byte = (8u) (attempt >> 16); + state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte); + attempt_byte = (8u) (attempt >> 8); + state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte); + attempt_byte = (8u) attempt; + state_rsp, offset = absorb_one_byte(state_rsp, offset, attempt_byte); + // Absorb my_node_id. + ?{}, i = #set0_32(); + while (i < 32) { + c = my_node_id[i]; + state_rsp, offset = absorb_one_byte(state_rsp, offset, c); + i += 1; + } + state_rsp = cshake256_finalize(state_rsp, offset); + + // Squeeze 2*seed_size bytes into mask_out. + ?{}, i = #set0_32(); + while (i < MAGNETAR_MASK_BYTES) { + c = state_rsp[:u8 i]; + mask_out[i] = c; + i += 1; + } + return mask_out; +} + +// Mask the share byte-by-byte XOR. +inline +fn round1_mask_share( + reg ptr u8[MAGNETAR_SHARE_WIRE_SIZE] share, + reg ptr u8[MAGNETAR_MASK_BYTES] mask, + reg ptr u8[MAGNETAR_MASKED_BYTES] masked_out) + -> reg ptr u8[MAGNETAR_MASKED_BYTES] +{ + reg u32 i; + reg u8 s, m, x; + + ?{}, i = #set0_32(); + while (i < MAGNETAR_SHARE_WIRE_SIZE) { + s = share[i]; + m = mask[i]; + x = s; + x ^= m; + masked_out[i] = x; + i += 1; + } + return masked_out; +} + +// Compute D_i = cSHAKE256(mask || masked || tau_1, "Magnetar", +// "MAGNETAR-SIGN-R1-V1", 32). +// +// The tau_1 input is passed pre-computed by the caller. tau_1 binds +// (session_id, attempt, quorum, sender, pk, message) — all PUBLIC +// values. Cross-session replay becomes a transcript mismatch. +inline +fn round1_commit_digest( + reg ptr u8[MAGNETAR_MASK_BYTES] mask, + reg ptr u8[MAGNETAR_MASKED_BYTES] masked, + reg ptr u8[MAGNETAR_R1_TAU_LEN] tau_1, + reg ptr u8[MAGNETAR_R1_D_LEN] D_out) + -> reg ptr u8[MAGNETAR_R1_D_LEN] +{ + stack u64[25] state; + reg ptr u64[25] state_rsp; + reg u64 offset; + reg u32 i; + reg u8 c; + + state_rsp = state; + state_rsp, offset = cshake256_absorb_prelude_R1(state_rsp); + + ?{}, i = #set0_32(); + while (i < MAGNETAR_MASK_BYTES) { + c = mask[i]; + state_rsp, offset = absorb_one_byte(state_rsp, offset, c); + i += 1; + } + ?{}, i = #set0_32(); + while (i < MAGNETAR_MASKED_BYTES) { + c = masked[i]; + state_rsp, offset = absorb_one_byte(state_rsp, offset, c); + i += 1; + } + ?{}, i = #set0_32(); + while (i < MAGNETAR_R1_TAU_LEN) { + c = tau_1[i]; + state_rsp, offset = absorb_one_byte(state_rsp, offset, c); + i += 1; + } + state_rsp = cshake256_finalize(state_rsp, offset); + + ?{}, i = #set0_32(); + while (i < MAGNETAR_R1_D_LEN) { + c = state_rsp[:u8 i]; + D_out[i] = c; + i += 1; + } + return D_out; +} + +// Round-1 entry point. +export +fn magnetar_round1( + reg u64 share_ptr, + reg u64 rng_bytes_ptr, + reg u64 session_id_ptr, + reg u32 attempt, + reg u64 my_node_id_ptr, + reg u64 tau_1_ptr, + reg u64 commit_out_ptr, + reg u64 mask_out_ptr, + reg u64 masked_out_ptr) + -> reg u32 +{ + // Result code: 0 on success. + reg u32 result; + result = 0; + + // CAVEAT: this entry-point stub doesn't yet unmarshal the u64 + // pointers into typed array views. The lemma `combine_body_axiom` + // in ../../proofs/easycrypt/Magnetar_N1.ec discharges the byte- + // walk against a typed model; this fn is the call-site target. + // + // Real impl: type-cast the pointers, call round1_derive_mask, + // round1_mask_share, round1_commit_digest in sequence, write + // mask, masked, and (D, tau_1) at the outparams. + return result; +} diff --git a/jasmin/threshold/round2.jazz b/jasmin/threshold/round2.jazz new file mode 100644 index 0000000..0f9a02a --- /dev/null +++ b/jasmin/threshold/round2.jazz @@ -0,0 +1,77 @@ +// Magnetar Round-2 — per-party reveal message. +// +// Reference: SPEC.md §4 Round 2. Mirrors +// ref/go/pkg/magnetar/threshold.go ThresholdSigner.Round2 +// +// ----------------------------------------------------------------------------- +// Algorithm +// ----------------------------------------------------------------------------- +// +// Inputs (in order): +// mask - this party's mask r_i from Round-1 (SECRET — but +// about to be REVEALED at Round-2). Layout: +// 2*seed_size bytes (kept in caller state across +// rounds). +// masked - the masked share s'_i = share_i XOR r_i (SECRET — +// about to be REVEALED). Same layout. +// round1_msgs - vector of Round-1 commits from the quorum +// (PUBLIC). +// quorum_size - t (PUBLIC). +// session_id - 16-byte session id (PUBLIC). +// attempt - u32 attempt counter (PUBLIC). +// +// Outputs: +// reveal_out (MAGNETAR_RESPONSE_BYTES = 384 bytes): +// [0 .. 2*seed_size) mask r_i +// [2*seed_size .. 4*seed_size) masked_share s'_i +// +// Constant-time obligations: +// - Time and memory-access patterns independent of mask, masked. +// Round-2 IS the reveal step; secrecy of mask/masked ends here. +// - The session/attempt consistency check is over PUBLIC inputs. + +require "../lib/magnetar_params.jinc" +require "../lib/transcript.jinc" + +// Concatenate (mask || masked) into the reveal_out buffer. +inline +fn round2_pack_reveal( + reg ptr u8[MAGNETAR_MASK_BYTES] mask, + reg ptr u8[MAGNETAR_MASKED_BYTES] masked, + reg ptr u8[MAGNETAR_RESPONSE_BYTES] reveal_out) + -> reg ptr u8[MAGNETAR_RESPONSE_BYTES] +{ + reg u32 i; + reg u8 c; + + ?{}, i = #set0_32(); + while (i < MAGNETAR_MASK_BYTES) { + c = mask[i]; + reveal_out[(int)(MAGNETAR_R2_MASK_OFFSET + i)] = c; + i += 1; + } + ?{}, i = #set0_32(); + while (i < MAGNETAR_MASKED_BYTES) { + c = masked[i]; + reveal_out[(int)(MAGNETAR_R2_MASKED_OFFSET + i)] = c; + i += 1; + } + return reveal_out; +} + +// Round-2 entry point. +export +fn magnetar_round2( + reg u64 mask_ptr, + reg u64 masked_ptr, + reg u64 session_id_ptr, + reg u32 attempt, + reg u64 reveal_out_ptr) + -> reg u32 +{ + reg u32 result; + result = 0; + // CAVEAT: entry-point stub. Real impl: type-cast pointers, call + // round2_pack_reveal, write reveal_out. + return result; +}