jasmin: lib + slh-dsa + threshold sources for N1-analog byte-equality

Jasmin scaffolding for the Magnetar high-assurance track, mirroring
Pulsar's structure but specialized to SLH-DSA-SHAKE-192s. Surface area:

lib/ — shared protocol primitives:
  magnetar_params.jinc    — Shamir prime, seed size, quorum cap,
                            wire-format constants
  lagrange_gf257.jinc     — byte-wise Lagrange coefficient computation
                            over GF(257); constant-time over (public)
                            eval-point set
  transcript.jinc         — cSHAKE256 absorption primitives with the
                            MAGNETAR-*-V1 customisation tags
  seed.jinc               — pack_bytesum_be: byteSum vector → big-
                            endian byte string for the
                            MAGNETAR-SEED-SHARE-V1 mix

threshold/ — protocol-layer .jazz files:
  round1.jazz             — per-party Round-1 commit (mask derive,
                            share mask, D_i digest)
  round2.jazz             — per-party Round-2 reveal (mask || masked)
  combine.jazz            — aggregator: commit re-derive, Lagrange
                            reconstruct, mix-to-seed, SLH-DSA dispatch

slh-dsa/ — single-party reference (libjade-SLH-DSA when upstream
            lands; today routes through cloudflare/circl). README +
            fetch.sh skeleton.

Structural simplification: ~600 LOC vs Pulsar's ~1500 LOC. The
elimination comes from byte-wise Z_257 arithmetic (no Montgomery,
no NTT, no polynomial vectors), no rejection-loop kappa state, and
the monolithic SLH-DSA signature output (no FIPS 204 §3.5.5
c_tilde/z/h decomposition).

The .jazz files type-check under jasminc -until_typing and target
jasmin-ct clean. The CI gate scripts/checks/jasmin.sh is skip-
friendly if jasminc is not on PATH.
This commit is contained in:
Hanzo AI
2026-05-19 08:39:06 -07:00
parent 30e71f1271
commit 7795fb7b42
10 changed files with 1107 additions and 0 deletions
+123
View File
@@ -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.
+174
View File
@@ -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;
}
+84
View File
@@ -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;
+39
View File
@@ -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;
}
+117
View File
@@ -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;
}
+56
View File
@@ -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.
+15
View File
@@ -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
+214
View File
@@ -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;
}
+208
View File
@@ -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;
}
+77
View File
@@ -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;
}