diff --git a/.github/workflows/lean.yml b/.github/workflows/lean.yml index 988672d0a..a7a1e8556 100644 --- a/.github/workflows/lean.yml +++ b/.github/workflows/lean.yml @@ -24,10 +24,10 @@ jobs: - name: Forbid proof escapes run: | ! grep -rnE '\b(sorry|sorryAx|admit|native_decide|unsafe|implemented_by)\b|#exit' \ - --include='*.lean' formal/xmss | grep -vE ':[0-9]+: *(--|/-)' + --include='*.lean' --exclude-dir=.lake formal/xmss | grep -vE ':[0-9]+: *(--|/-)' # Installs the toolchain from `formal/xmss/lean-toolchain`, fetches the # mathlib cache, and runs `lake build` on the default target, which - # elaborates the root module and with it both `#guard_msgs` checks. The + # elaborates the root module and with it the `#guard_msgs` check. The # checked-in manifest is used as is: no `lake update`. - uses: leanprover/lean-action@v1 with: @@ -47,3 +47,27 @@ jobs: with: name: xmss-axioms path: ${{ runner.temp }}/axioms.txt + + sphincs-formalization: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Forbid proof escapes + run: | + ! grep -rnE '\b(sorry|sorryAx|admit|native_decide|unsafe|implemented_by)\b|#exit' \ + --include='*.lean' --exclude-dir=.lake formal/sphincs | grep -vE ':[0-9]+: *(--|/-)' + - uses: leanprover/lean-action@v1 + with: + lake-package-directory: formal/sphincs + - name: Check the axiom footprint + working-directory: formal/sphincs + run: | + printf 'import SphincsSecurity\n#print axioms SphincsSecurity.sphincs_has_127_bits_of_classical_security\n' \ + > "$RUNNER_TEMP/axioms.lean" + lake env lean "$RUNNER_TEMP/axioms.lean" | tee "$RUNNER_TEMP/axioms.txt" + grep -qF "'SphincsSecurity.sphincs_has_127_bits_of_classical_security' depends on axioms: [propext, Classical.choice, Quot.sound]" \ + "$RUNNER_TEMP/axioms.txt" + - uses: actions/upload-artifact@v4 + with: + name: sphincs-axioms + path: ${{ runner.temp }}/axioms.txt diff --git a/AGENTS.md b/AGENTS.md index b53d0143a..0bd87b326 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ A minimal virtual machine and recursive SNARK for signature aggregation and blob - `doc/leanvm/` is the LaTeX project describing the machine ISA and the snark that proves it. Its root is `doc/leanvm/main.tex`; build it with `cd doc/leanvm && latexmk -pdf main.tex`, which writes to the gitignored `doc/leanvm/.build/`. Sections live in `doc/leanvm/body/`, numbered `01`..`10` plus the lettered annexes `a` (ring switching), `b` (the PCS), `c` (Flock), and `d` (novel basis and additive NTT), and every symbol is defined once in `doc/leanvm/preamble/macros.tex`. If latexmk fails oddly (a bibtex error, or a missing `main.log`) right after inputs are renamed or `refs.bib` is edited, remove `doc/leanvm/.build` and rerun; it has not reproduced on unchanged inputs. **Drafting one section:** each section file carries a `% !TeX root` comment pointing at its generated driver in `doc/leanvm/drafts/`, so the LaTeX build key (`F5`, or the extension's `cmd+alt+b`) compiles only that section, numbered as in the full document and with cross-references and citations resolved against `.build/main.aux`; in `main.tex` the same key builds everything. Run `doc/leanvm/make-drafts.sh` after adding, renaming or renumbering a section. - `doc/xmss/` is the standalone XMSS specification; `crates/xmss` implements its hash inputs and signature verification. - `doc/sphincs/` is the standalone specification of the concrete SPHINCS+ instance used where statelessness matters; its root is `doc/sphincs/main.tex`, built the same way as `doc/xmss`, and implemented by `crates/sphincs`. It uses the same BLAKE2s primitive and target-sum encoding shape as XMSS, with its own tweak layout, target sum, and signing search. -- `formal/xmss/` is a Lean 4 proof (over VCVio) of the ideal XMSS scheme's classical random-oracle security, `xmss_has_127_bits_of_classical_security`. `XmssSecurity/Statement.lean` is the only module a reviewer has to read: the concrete parameters, the byte layout of every hash input, the three algorithms, the game, and the claim. `lake exe cache get` once, then `lake build`. SPHINCS has no formalization; its security section is a target, not a theorem. +- `formal/xmss/` and `formal/sphincs/` are Lean 4 proofs (over VCVio) of the ideal schemes' classical random-oracle security, `xmss_has_127_bits_of_classical_security` and `sphincs_has_127_bits_of_classical_security`. Each project's `Statement.lean`, under `XmssSecurity/` or `SphincsSecurity/`, contains the concrete parameters, the byte layout of every hash input, the three algorithms, the game, and the claim. `lake exe cache get` once, then `lake build`. - The one hash function is BLAKE2s, in `primitives::hash`: scalar, streaming, keyed, and a lane-transposed batched form for the PCS Merkle tree. The VM proves one compression per opcode, and BLAKE2s takes the byte counter and final-block flag as ordinary compression inputs, so repeated opcodes hash arbitrary byte strings by carrying the chaining value and setting the counter and final flag for each block. - `crates/lean_compiler/zkDSL.md` documents the (pythonic) zkDSL (that compiles to the ISA that our VM runs, and that our snark proves). diff --git a/README.md b/README.md index 20bbb1225..8e212b987 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ leanSPHINCS aggregation - 250/s + 280/s leanDA commitment @@ -95,12 +95,12 @@ cargo run --release -- aggregate --sphincs 245 --log-inv-rate 1 --repeat 3 ``` aggregation, 245 SPHINCS signatures - cycles (VM steps) : 2,351,886 = 2^21.165 - details : XOR 2^19.239 (26.3%) MUL 2^19.222 (26.0%) SET 2^18.833 (19.9%) DEREF 2^18.711 (18.2%) BLAKE2S 2^16.992 (5.5%) JUMP 2^16.543 (4.1%) MEMORY 2^21.679 TOTAL_COMMITTED 2^26.553 - proof size : 313.8 KiB - proving time : 0.972 s Β± 3.4% peak memory 11.476 GiB - per signature : 252.028 signatures/s - verifying : 3.969 ms + cycles (VM steps) : 2,132,425 = 2^21.024 + details : XOR 2^18.951 (23.8%) MUL 2^18.93 (23.4%) SET 2^18.847 (22.1%) DEREF 2^18.711 (20.1%) BLAKE2S 2^16.992 (6.1%) JUMP 2^16.543 (4.5%) MEMORY 2^21.564 TOTAL_COMMITTED 2^26.301 + proof size : 300.1 KiB + proving time : 0.861 s Β± 1.5% peak memory 9.348 GiB + per signature : 284.603 signatures/s + verifying : 3.841 ms ``` ### data availability diff --git a/crates/rec_aggregation/guests/lean_ethereum.py b/crates/rec_aggregation/guests/lean_ethereum.py index 4d1101649..1e7739d21 100644 --- a/crates/rec_aggregation/guests/lean_ethereum.py +++ b/crates/rec_aggregation/guests/lean_ethereum.py @@ -380,30 +380,21 @@ # them, so the buffer holds three lanes and the top 16 are never read. SP_BIT_LANES = 3 SP_BIT_CELLS = SP_BIT_LANES * BASE_FIELD_BITS -# Tweak types (the tweak's first byte). Types 0 and 5 are the seed derivation's, -# which is a signer's own business: nothing in-circuit ever verifies one. -SP_TW_PRF = 0 -SP_TW_CHAIN = 1 -SP_TW_LEAF = 2 -SP_TW_NODE = 3 -SP_TW_ENC = 4 -SP_TW_FTS_PRF = 5 -SP_TW_FTS_LEAF = 6 -SP_TW_FTS_NODE = 7 -SP_TW_FTS_ROOTS = 8 -SP_TW_MSG = 9 -# enc(t, lay, tau, p, j) packs t at bit 0, lay at 8, tau at 16, p at 48 and j at -# 80, fourteen bytes of fields and two of padding. Every field this instance uses -# is small enough that none straddles the 64-bit lane boundary (tau < 2^26 at bit -# 16, p <= 334 at bit 48, j < 2^12 at bit 80), so a tweak cell is -# `t + lay*2^8 + tau*2^16 + p*2^48` in lane 0 plus `j*2^16` in lane 1, and every -# term is one field addition. SP_TAU_POS and SP_J_POS are where a bit of tau or of -# j weighs in the coordinate basis, the j position already carrying the lane, so -# nothing has to be multiplied by Y afterwards. -SP_LAY_MUL = 2 ** 8 -SP_P_MUL = 2 ** 48 -SP_TAU_POS = 16 -SP_J_POS = BASE_FIELD_BITS + 16 +# Native tweak prefixes, including the protocol domain separator and type. +SP_TW_CHAIN = SP_TW_CHAIN_PLACEHOLDER +SP_TW_LEAF = SP_TW_LEAF_PLACEHOLDER +SP_TW_NODE = SP_TW_NODE_PLACEHOLDER +SP_TW_ENC = SP_TW_ENC_PLACEHOLDER +SP_TW_FTS_LEAF = SP_TW_FTS_LEAF_PLACEHOLDER +SP_TW_FTS_NODE = SP_TW_FTS_NODE_PLACEHOLDER +SP_TW_FTS_ROOTS = SP_TW_FTS_ROOTS_PLACEHOLDER +SP_TW_MSG = SP_TW_MSG_PLACEHOLDER +# Tweak layout: protocol_domain_sep | type | layer | zero | p | tree | index. +# Each 32-bit field stays within one 64-bit lane. +SP_LAY_MUL = 2 ** 16 +SP_P_MUL = 2 ** 32 +SP_TAU_POS = BASE_FIELD_BITS +SP_J_POS = BASE_FIELD_BITS + 32 SP_CHAIN_MUL = SP_CHAIN_LENGTH * SP_P_MUL # chain i's tweaks start at p = 2^w * i # The encoding counter, LE_32 in the low four bytes of its cell: bounded by # decomposing exactly that many bits, so the guest accepts no preimage the native @@ -2450,13 +2441,19 @@ def verify_sig_sphincs(signer): secret = StackBuf(WORDS_PER_BLOCK) hint_witness(secret[0:1], "sp_fts_secrets") fts_leaf = StackBuf(WORDS_PER_BLOCK) - blake2s([SP_TW_FTS_LEAF + kappa * SP_LAY_MUL + idx_tau + sp_bit_field(bits, leaf_off, SP_A, SP_J_POS), pp], [secret[0], 0], fts_leaf, counter=48, final=1) + node_index = sp_bit_field(bits, leaf_off, SP_A, SP_J_POS) + blake2s([SP_TW_FTS_LEAF + kappa * SP_LAY_MUL + idx_tau + node_index, pp], [secret[0], 0], fts_leaf, counter=48, final=1) node = fts_leaf[0] for level in unroll(0, SP_A): sibling = hint_witness("sp_fts_paths") children = order_children(node, sibling, bits[GEN ** (leaf_off + level)]) parent = StackBuf(WORDS_PER_BLOCK) - blake2s([SP_TW_FTS_NODE + kappa * SP_LAY_MUL + const((level + 1) * SP_P_MUL) + idx_tau + sp_bit_field(bits, leaf_off + level + 1, SP_A - level - 1, SP_J_POS), pp], children, parent) + if const(level + 1 == SP_A): + node_index = 0 + else: + # The index fits in one lane; clearing its low bit makes division by GEN a right shift. + node_index = (node_index + bits[GEN ** (leaf_off + level)] * COORD_BASIS[SP_J_POS]) / GEN + blake2s([SP_TW_FTS_NODE + kappa * SP_LAY_MUL + const((level + 1) * SP_P_MUL) + idx_tau + node_index, pp], children, parent) node = parent[0] roots[kappa] = node fts_key = StackBuf(WORDS_PER_BLOCK) @@ -2474,13 +2471,18 @@ def verify_sig_sphincs(signer): lay = SP_D - 1 - step leaf_index_off = SP_SUFFIX[lay + 1] tau_field = sp_bit_field(bits, SP_SUFFIX[lay], SP_H - SP_SUFFIX[lay], SP_TAU_POS) - tw_pos = tau_field + sp_bit_field(bits, leaf_index_off, SP_HEIGHTS[lay], SP_J_POS) + lay * SP_LAY_MUL + node_index = sp_bit_field(bits, leaf_index_off, SP_HEIGHTS[lay], SP_J_POS) + tw_pos = tau_field + node_index + lay * SP_LAY_MUL node = sp_ots_leaf(tw_pos, pp, signed) for level in unroll(0, SP_HEIGHTS[lay]): sibling = hint_witness("sp_siblings") children = order_children(node, sibling, bits[GEN ** (leaf_index_off + level)]) parent = StackBuf(WORDS_PER_BLOCK) - blake2s([SP_TW_NODE + lay * SP_LAY_MUL + const((level + 1) * SP_P_MUL) + tau_field + sp_bit_field(bits, leaf_index_off + level + 1, SP_HEIGHTS[lay] - level - 1, SP_J_POS), pp], children, parent) + if const(level + 1 == SP_HEIGHTS[lay]): + node_index = 0 + else: + node_index = (node_index + bits[GEN ** (leaf_index_off + level)] * COORD_BASIS[SP_J_POS]) / GEN + blake2s([SP_TW_NODE + lay * SP_LAY_MUL + const((level + 1) * SP_P_MUL) + tau_field + node_index, pp], children, parent) node = parent[0] signed = node assert signed == signer[1] diff --git a/crates/rec_aggregation/src/aggregation.rs b/crates/rec_aggregation/src/aggregation.rs index 89f7b69a8..fe3eef387 100644 --- a/crates/rec_aggregation/src/aggregation.rs +++ b/crates/rec_aggregation/src/aggregation.rs @@ -148,11 +148,10 @@ const _: () = assert!((2 + sphincs::NUM_FTS_TREES).is_multiple_of(4)); // dynamically sized `HeapBuf` gets no compile-time index check, so a wider // digest would read leaf indices from cells nothing writes. const _: () = assert!(sphincs::DIGEST_BITS <= 3 * 64); -// Every tweak field the guest packs must stay inside the byte range the native -// `enc` gives it: `tau` at bit 16 below `p` at 48, `p` below the 64-bit lane -// boundary, and `j` inside its four bytes at bit 80. +// The guest packs each tweak field into its own 32-bit word: p at bit 32, +// tau at bit 64, and j at bit 96. const _: () = assert!(sphincs::H <= 32); -const _: () = assert!(sphincs::CHAIN_LEN * sphincs::V < 1 << 16); +const _: () = assert!(sphincs::CHAIN_LEN * sphincs::V < 1 << 32); const _: () = assert!(sphincs::A <= 32 && sphincs::HEIGHTS[0] <= 32); /// A count as the guest carries it: in the exponent, `g^n`. @@ -263,10 +262,9 @@ fn tweak_cell(tweak_type: u8, sub_position: u32) -> F192 { /// What bit `b` of the epoch weighs in a tweak's index field, so an index is its /// set bits summed. The one property of the layout this assumes is that the -/// index field is linear in the index, which a leaf proof at the benchmark epoch -/// exercises for every bit. +/// index field is linear in the index. Subtract the constant protocol prefix. fn tweak_index_weight(b: usize) -> F192 { - pack_16_bytes(&xmss::make_tweak(0, 0, 1 << b)) + pack_16_bytes(&xmss::make_tweak(0, 0, 1 << b)) + pack_16_bytes(&xmss::make_tweak(0, 0, 0)) } /// The signer-set digest: plain BLAKE2s of one byte string, laid out in whole /// 64-byte blocks so the guest can absorb it four cells at a time @@ -3002,7 +3000,7 @@ fn placeholder_map(kbc: usize) -> BTreeMap { // The SPHINCS instance. Its tweaks are derived per signature from the index // the message digest picks, where XMSS's come from one public epoch, so the - // guest needs only the shape. + // guest receives the shape and the native tweak prefixes. let dsl_list = |values: &[usize]| { let inner: Vec = values.iter().map(usize::to_string).collect(); format!("[{}]", inner.join(", ")) @@ -3016,6 +3014,21 @@ fn placeholder_map(kbc: usize) -> BTreeMap { ps("SP_H", sphincs::H.to_string()); ps("SP_HEIGHTS", dsl_list(&sphincs::HEIGHTS)); ps("SP_SUFFIX", dsl_list(&sphincs::SUFFIX)); + for (name, tag) in [ + ("SP_TW_CHAIN", sphincs::TWEAK_CHAIN), + ("SP_TW_LEAF", sphincs::TWEAK_LEAF), + ("SP_TW_NODE", sphincs::TWEAK_NODE), + ("SP_TW_ENC", sphincs::TWEAK_ENC), + ("SP_TW_FTS_LEAF", sphincs::TWEAK_FTS_LEAF), + ("SP_TW_FTS_NODE", sphincs::TWEAK_FTS_NODE), + ("SP_TW_FTS_ROOTS", sphincs::TWEAK_FTS_ROOTS), + ("SP_TW_MSG", sphincs::TWEAK_MSG), + ] { + ps( + name, + dsl_u128(pack_16_bytes(&sphincs::tweak(tag, 0, 0, 0, 0))).to_string(), + ); + } rep } @@ -3228,6 +3241,64 @@ mod tests { aggregate(&[], at_epoch(signers, XMSS_EPOCH_A), vec![], &[], None, LOG_INV_RATE).expect("leaf aggregates") } + #[test] + fn keygen_and_verification_hash_domains_are_disjoint() { + let xmss_tags = [ + xmss::TWEAK_TYPE_PRF, + xmss::TWEAK_TYPE_CHAIN, + xmss::TWEAK_TYPE_WOTS_PK, + xmss::TWEAK_TYPE_MERKLE, + xmss::TWEAK_TYPE_ENCODING, + xmss::TWEAK_TYPE_PARAMETER, + xmss::TWEAK_TYPE_FILLER, + ]; + let sphincs_tags = [ + sphincs::TWEAK_PRF, + sphincs::TWEAK_CHAIN, + sphincs::TWEAK_LEAF, + sphincs::TWEAK_NODE, + sphincs::TWEAK_ENC, + sphincs::TWEAK_FTS_PRF, + sphincs::TWEAK_FTS_LEAF, + sphincs::TWEAK_FTS_NODE, + sphincs::TWEAK_FTS_ROOTS, + sphincs::TWEAK_MSG, + sphincs::TWEAK_PARAMETER, + ]; + let domains: BTreeSet<_> = xmss_tags + .into_iter() + .map(|tag| xmss::make_tweak(tag, 0, 0)) + .chain(sphincs_tags.into_iter().map(|tag| sphincs::tweak(tag, 0, 0, 0, 0))) + .collect(); + assert_eq!(domains.len(), xmss_tags.len() + sphincs_tags.len()); + } + + #[test] + fn signature_tweaks_align_with_distinct_domains() { + for (xmss_tag, sphincs_tag) in [ + (xmss::TWEAK_TYPE_CHAIN, sphincs::TWEAK_CHAIN), + (xmss::TWEAK_TYPE_WOTS_PK, sphincs::TWEAK_LEAF), + (xmss::TWEAK_TYPE_MERKLE, sphincs::TWEAK_NODE), + (xmss::TWEAK_TYPE_ENCODING, sphincs::TWEAK_ENC), + ] { + for position in [0, 1, u32::MAX] { + for index in [0, 1, 3, 0xa0b0_c0d0, u32::MAX] { + let xmss_tweak = xmss::make_tweak(xmss_tag, position, index); + let sphincs_tweak = sphincs::tweak(sphincs_tag, 0, 0, position, index); + assert_eq!(&xmss_tweak[1..], &sphincs_tweak[1..]); + assert_ne!(xmss_tweak[0], sphincs_tweak[0]); + let mut guest_tweak = tweak_cell(xmss_tag, position); + for bit in 0..32 { + if index & (1 << bit) != 0 { + guest_tweak += tweak_index_weight(bit); + } + } + assert_eq!(guest_tweak, pack_16_bytes(&xmss_tweak)); + } + } + } + } + type RawSphincs = (SphincsPublicKey, sphincs::Message, SphincsSignature); fn prove_sphincs_leaf(signers: &[RawSphincs]) -> EthereumProof { @@ -3318,7 +3389,7 @@ mod tests { .into_iter() .map(|tag| { let signed: sphincs::Message = std::array::from_fn(|i| tag.wrapping_mul(i as u8 + 1)); - let signature = sphincs::sign(&mut rng, &secret_key, &signed).expect("signs"); + let signature = sphincs::sign(&secret_key, &signed).expect("signs"); (public_key, signed, signature) }) .collect(); diff --git a/crates/rec_aggregation/src/signers_cache.rs b/crates/rec_aggregation/src/signers_cache.rs index 9499058f9..1515bd0aa 100644 --- a/crates/rec_aggregation/src/signers_cache.rs +++ b/crates/rec_aggregation/src/signers_cache.rs @@ -21,7 +21,7 @@ use xmss::*; type CachedSignature = (XmssPublicKey, XmssSignature); -const SCHEMA_VERSION: u32 = 2; +const SCHEMA_VERSION: u32 = 3; /// The epoch `get_signers` signs at. SPHINCS has none. pub const XMSS_EPOCH_A: Epoch = 3_000_000_007; @@ -53,13 +53,7 @@ fn compute_signer(index: usize, epoch: Epoch) -> CachedSignature { let mut seed = [10u8; 32]; seed[..8].copy_from_slice(&(index as u64).to_le_bytes()); let (sk, pk) = xmss::key_gen_from_seed(seed, KEY_START, KEY_END).expect("keygen"); - let sig = xmss::sign( - &mut StdRng::seed_from_u64(index as u64), - &sk, - &message_for(epoch), - epoch, - ) - .expect("sign"); + let sig = xmss::sign(&sk, &message_for(epoch), epoch).expect("sign"); (pk, sig) } @@ -207,7 +201,7 @@ fn compute_sphincs_signer(index: usize) -> CachedSphincsSignature { let mut rng = StdRng::seed_from_u64(0x5F1A_C500 ^ index as u64); let (secret_key, public_key) = sphincs::key_gen(&mut rng); let message = sphincs_message(index); - let signature = sphincs::sign(&mut rng, &secret_key, &message).expect("sign"); + let signature = sphincs::sign(&secret_key, &message).expect("sign"); (public_key, message, signature) } @@ -220,6 +214,7 @@ fn sphincs_footprint() -> u64 { sphincs_message(0).hash(&mut hasher); sphincs_message(1).hash(&mut hasher); ( + sphincs::MASTER_SECRET_LEN, sphincs::V, sphincs::W, sphincs::TARGET_SUM, diff --git a/crates/sphincs/src/fts.rs b/crates/sphincs/src/fts.rs index f7fc428ac..f139f754f 100644 --- a/crates/sphincs/src/fts.rs +++ b/crates/sphincs/src/fts.rs @@ -18,7 +18,7 @@ pub struct FtsOpening { } /// `s_{idx,kappa,j} = Th(P, tw_ftsprf(idx,kappa,j), S)`. -fn fts_secret(pp: &PublicParam, master: &Digest, idx: u64, kappa: usize, j: usize) -> Digest { +fn fts_secret(pp: &PublicParam, master: &MasterSecret, idx: u64, kappa: usize, j: usize) -> Digest { th(pp, &tweak(TWEAK_FTS_PRF, kappa, idx as u32, 0, j as u32), master) } @@ -38,7 +38,7 @@ fn fts_key_of_roots(pp: &PublicParam, idx: u64, roots: &[Digest; NUM_FTS_TREES]) /// `Fts.key` and `Fts.open` together, the forest being built once. `u[k-1]` is /// ignored: its tree is the dropped one. -pub fn fts_open(pp: &PublicParam, master: &Digest, idx: u64, u: &[u32; K]) -> (Digest, FtsOpening) { +pub fn fts_open(pp: &PublicParam, master: &MasterSecret, idx: u64, u: &[u32; K]) -> (Digest, FtsOpening) { let mut opening = FtsOpening { secrets: [[0; N]; NUM_FTS_TREES], paths: [[[0; N]; A]; NUM_FTS_TREES], diff --git a/crates/sphincs/src/hash.rs b/crates/sphincs/src/hash.rs index cdeb7e665..3bda543a2 100644 --- a/crates/sphincs/src/hash.rs +++ b/crates/sphincs/src/hash.rs @@ -11,7 +11,9 @@ use crate::*; pub const TWEAK_LEN: usize = 16; pub type Tweak = [u8; TWEAK_LEN]; -// Tweak types, the tweak's first byte, so no two kinds of call can alias. +pub const PROTOCOL_DOMAIN_SEP: u8 = 1; + +// Tweak types (byte 1). pub const TWEAK_PRF: u8 = 0; pub const TWEAK_CHAIN: u8 = 1; pub const TWEAK_LEAF: u8 = 2; @@ -22,18 +24,20 @@ pub const TWEAK_FTS_LEAF: u8 = 6; pub const TWEAK_FTS_NODE: u8 = 7; pub const TWEAK_FTS_ROOTS: u8 = 8; pub const TWEAK_MSG: u8 = 9; +pub const TWEAK_PARAMETER: u8 = 10; +pub const TWEAK_RANDOMIZER: u8 = 12; -/// `enc(t, lay, tau, p, j)`: fourteen bytes of little-endian fields and two of -/// padding. `lay` is a layer of the hypertree or a tree of a few-time forest, -/// and is byte wide. +/// `[protocol_domain_sep:1 | type:1 | layer:1 | zero:1 | p:4 | tree:4 | index:4]`, little endian. +/// `lay` identifies a hypertree layer or a tree of a few-time forest. pub fn tweak(t: u8, lay: usize, tau: u32, p: u32, j: u32) -> Tweak { debug_assert!(lay < 256); let mut tw = [0u8; TWEAK_LEN]; - tw[0] = t; - tw[1] = lay as u8; - tw[2..6].copy_from_slice(&tau.to_le_bytes()); - tw[6..10].copy_from_slice(&p.to_le_bytes()); - tw[10..14].copy_from_slice(&j.to_le_bytes()); + tw[0] = PROTOCOL_DOMAIN_SEP; + tw[1] = t; + tw[2] = lay as u8; + tw[4..8].copy_from_slice(&p.to_le_bytes()); + tw[8..12].copy_from_slice(&tau.to_le_bytes()); + tw[12..16].copy_from_slice(&j.to_le_bytes()); tw } diff --git a/crates/sphincs/src/lib.rs b/crates/sphincs/src/lib.rs index 895c18308..5186138b5 100644 --- a/crates/sphincs/src/lib.rs +++ b/crates/sphincs/src/lib.rs @@ -8,9 +8,8 @@ //! the exact byte string `tweak | P | payload` truncated to `n = 128` bits (the //! `hash` module), and the tweak names one hash call in the whole structure. //! -//! Secrets are the seed-derived implementation of the specification's "Seed -//! derivation" remark: a key pair is one master secret, and a signer holds the -//! 1024-byte layer-0 cache of its "Signer state" remark. +//! One 32-byte master seed derives the public parameter and all signing secrets. +//! The signer caches public nodes of the top tree. #![cfg_attr(not(test), warn(unused_crate_dependencies))] @@ -27,10 +26,14 @@ pub use sphincs::*; pub const N: usize = 16; pub type Digest = [u8; N]; -/// The public parameter, sampled per key pair, which separates users. +/// The public parameter derived from the master seed. pub const PUBLIC_PARAM_LEN: usize = 16; pub type PublicParam = [u8; PUBLIC_PARAM_LEN]; +/// The master secret used to derive all WOTS and FORS secrets. +pub const MASTER_SECRET_LEN: usize = 32; +pub type MasterSecret = [u8; MASTER_SECRET_LEN]; + /// The per-signature randomizer the message digest is computed under. pub const RANDOMIZER_LEN: usize = 16; pub type Randomizer = [u8; RANDOMIZER_LEN]; @@ -82,7 +85,7 @@ pub const DIGEST_BYTES: usize = DIGEST_BITS / 8; pub const PUB_KEY_SIZE: usize = N + PUBLIC_PARAM_LEN; /// A secret key is its public parameter and its master secret; the rest is derived. -pub const SECRET_KEY_SIZE: usize = PUBLIC_PARAM_LEN + N; +pub const SECRET_KEY_SIZE: usize = PUBLIC_PARAM_LEN + MASTER_SECRET_LEN; pub const SIG_SIZE: usize = RANDOMIZER_LEN + NUM_FTS_TREES * (1 + A) * N + D * (COUNTER_LEN + V * N) + H * N; /// Calls to the hash function one verification makes: the digest, `Fts.recover`, diff --git a/crates/sphincs/src/ots.rs b/crates/sphincs/src/ots.rs index 7b49c9f5a..b9c345c48 100644 --- a/crates/sphincs/src/ots.rs +++ b/crates/sphincs/src/ots.rs @@ -26,7 +26,7 @@ impl Pos { } /// `sk_{lay,tau,e,i} = Th(P, tw_prf(lay,tau,i,e), S)`. -pub fn ots_secret(pp: &PublicParam, master: &Digest, pos: Pos, i: usize) -> Digest { +pub fn ots_secret(pp: &PublicParam, master: &MasterSecret, pos: Pos, i: usize) -> Digest { th(pp, &tweak(TWEAK_PRF, pos.lay, pos.tau, i as u32, pos.e), master) } @@ -77,7 +77,7 @@ fn codeword(digest: &Digest) -> Option<[u8; V]> { /// opens. Deterministic in its inputs, which is what keeps one key to one /// codeword: a resumed or randomized search would leak two incomparable /// codewords and drop forgery to about `2^53`. -pub fn ots_sign(pp: &PublicParam, master: &Digest, pos: Pos, m: &Digest) -> Option<(u32, [Digest; V])> { +pub fn ots_sign(pp: &PublicParam, master: &MasterSecret, pos: Pos, m: &Digest) -> Option<(u32, [Digest; V])> { let (c, x) = (0..MAX_ENCODING_ATTEMPTS).find_map(|c| encode(pp, pos, m, c as u32).map(|x| (c as u32, x)))?; let signature = std::array::from_fn(|i| chain(pp, pos, i, 0, x[i] as usize, ots_secret(pp, master, pos, i))); Some((c, signature)) @@ -97,7 +97,7 @@ pub fn ots_leaf(pp: &PublicParam, pos: Pos, m: &Digest, c: u32, signature: &[Dig /// The leaf of the one-time key at `pos`, from the master secret: what key /// generation and every tree rebuild spend their hashes on. -pub fn ots_public_leaf(pp: &PublicParam, master: &Digest, pos: Pos) -> Digest { +pub fn ots_public_leaf(pp: &PublicParam, master: &MasterSecret, pos: Pos) -> Digest { let tips = std::array::from_fn(|i| chain(pp, pos, i, 0, CHAIN_LEN - 1, ots_secret(pp, master, pos, i))); ots_leaf_hash(pp, pos, &tips) } diff --git a/crates/sphincs/src/sphincs.rs b/crates/sphincs/src/sphincs.rs index 5c410b3cf..3b999e524 100644 --- a/crates/sphincs/src/sphincs.rs +++ b/crates/sphincs/src/sphincs.rs @@ -80,7 +80,7 @@ impl SphincsPublicKey { pub struct SphincsSecretKey { pub public_param: PublicParam, pub root: Digest, - master: Digest, + master: MasterSecret, cache: [Digest; CACHE_LEN], } @@ -283,7 +283,7 @@ fn build_up( /// `Gen`, on given `P` and master secret. Only layer 0 is built; the trees below /// it are built when a signature needs them. -pub fn key_gen_from(public_param: PublicParam, master: Digest) -> (SphincsSecretKey, SphincsPublicKey) { +pub fn key_gen_from(public_param: PublicParam, master: MasterSecret) -> (SphincsSecretKey, SphincsPublicKey) { let leaves = parallel::map_collect(1 << HEIGHTS[0], |e| { ots_public_leaf(&public_param, &master, Pos::new(0, 0, e as u32)) }); @@ -301,27 +301,17 @@ pub fn key_gen_from(public_param: PublicParam, master: Digest) -> (SphincsSecret ) } -/// The domain string the seed is expanded under, so a seed shared with -/// `xmss::key_gen_from_seed` gives unrelated keys. -const KEY_GEN_DOMAINSEP: &[u8] = b"sphincs/key-gen/v1"; - /// `Gen`, on a fresh key: the seed comes from `rng`, so nothing can regenerate /// the key. pub fn key_gen(rng: &mut impl CryptoRng) -> (SphincsSecretKey, SphincsPublicKey) { key_gen_from_seed(rng.random()) } -/// Deterministic [`key_gen`]: one seed always regenerates the same key pair. -/// `P` and the master secret are the two halves of a keyed hash of it, which is -/// uniform and independent in the random-oracle model the spec's sampling is -/// stated in (`doc/sphincs` Remark "Seed derivation"). -pub fn key_gen_from_seed(seed: [u8; 32]) -> (SphincsSecretKey, SphincsPublicKey) { - const _: () = assert!(PUBLIC_PARAM_LEN + N == primitives::hash::OUT_LEN); - let expanded = primitives::hash::keyed_hash(&seed, KEY_GEN_DOMAINSEP); - key_gen_from( - expanded[..PUBLIC_PARAM_LEN].try_into().unwrap(), - expanded[PUBLIC_PARAM_LEN..].try_into().unwrap(), - ) +/// Deterministic [`key_gen`]: the seed is the master secret, and a dedicated +/// tweak derives the public parameter from it. +pub fn key_gen_from_seed(seed: MasterSecret) -> (SphincsSecretKey, SphincsPublicKey) { + let parameter = th(&[0; PUBLIC_PARAM_LEN], &tweak(TWEAK_PARAMETER, 0, 0, 0, 0), &seed); + key_gen_from(parameter, seed) } impl SphincsSecretKey { @@ -378,19 +368,17 @@ impl SphincsSecretKey { } } -/// `Sig`. Stateless: it may be called on any message any number of times, but -/// security degrades with that number, the specification's claim being stated at -/// `2^24` signatures per key pair. -pub fn sign( - rng: &mut impl CryptoRng, - sk: &SphincsSecretKey, - message: &Message, -) -> Result { +/// Sign at most `2^24` messages per key. +/// Signing is deterministic and stateless. +pub fn sign(sk: &SphincsSecretKey, message: &Message) -> Result { // The digest is admissible when its last leaf index is zero, which is what // drops that tree from the forest; it takes 2^a attempts on average. let (randomizer, idx, u) = (0..MAX_DIGEST_ATTEMPTS) - .find_map(|_| { - let randomizer: Randomizer = rng.random(); + .find_map(|trial| { + let mut hasher = primitives::hash::Hasher::new(); + hasher.update(&tweak(TWEAK_RANDOMIZER, 0, 0, trial as u32, 0)); + hasher.update(&sk.public_param).update(&sk.master).update(message); + let randomizer = hasher.finalize()[..RANDOMIZER_LEN].try_into().unwrap(); let (idx, u) = message_digest(&sk.public_param, &sk.root, &randomizer, message); (u[K - 1] == 0).then_some((randomizer, idx, u)) }) diff --git a/crates/sphincs/tests/sphincs_tests.rs b/crates/sphincs/tests/sphincs_tests.rs index 4a8bea412..4317c96b4 100644 --- a/crates/sphincs/tests/sphincs_tests.rs +++ b/crates/sphincs/tests/sphincs_tests.rs @@ -14,17 +14,16 @@ fn keygen_sign_verify() { let (sk, pk) = test_key(0); assert_eq!(sk.public_key(), pk); let message = test_message(); - for round in 0..2 { - let signature = sign(&mut StdRng::seed_from_u64(round), &sk, &message).unwrap(); - verify(&pk, &message, &signature).unwrap(); - } + let signature = sign(&sk, &message).unwrap(); + verify(&pk, &message, &signature).unwrap(); + assert_eq!(sign(&sk, &message).unwrap(), signature); } #[test] fn serialized_sizes_and_roundtrip() { let (sk, pk) = test_key(1); let message = test_message(); - let signature = sign(&mut StdRng::seed_from_u64(7), &sk, &message).unwrap(); + let signature = sign(&sk, &message).unwrap(); let public_key_bytes = pk.flatten(); assert_eq!(public_key_bytes.len(), 32); @@ -41,7 +40,7 @@ fn serialized_sizes_and_roundtrip() { fn tampered_signatures_rejected() { let (sk, pk) = test_key(2); let message = test_message(); - let signature = sign(&mut StdRng::seed_from_u64(3), &sk, &message).unwrap(); + let signature = sign(&sk, &message).unwrap(); verify(&pk, &message, &signature).unwrap(); let mut other_message = message; @@ -97,7 +96,7 @@ fn tampered_signatures_rejected() { fn ots_counter_is_the_least_admissible() { let mut rng = StdRng::seed_from_u64(4); let public_param: PublicParam = rng.random(); - let master: Digest = rng.random(); + let master: MasterSecret = rng.random(); let pos = Pos::new(2, 1234, 56); let message: Digest = rng.random(); @@ -137,7 +136,7 @@ fn index_decomposition_is_a_bijection_onto_the_bottom_layer() { fn grinding_bits() { let mut rng = StdRng::seed_from_u64(6); let public_param: PublicParam = rng.random(); - let master: Digest = rng.random(); + let master: MasterSecret = rng.random(); let samples = 200; let counters: u64 = (0..samples) @@ -186,10 +185,25 @@ fn secret_key_survives_a_round_trip() { assert_eq!(reloaded.public_key(), pk); let message = test_message(); - let sig = sign(&mut StdRng::seed_from_u64(1), &reloaded, &message).unwrap(); + let sig = sign(&reloaded, &message).unwrap(); verify(&pk, &message, &sig).unwrap(); } +#[test] +fn secret_derivation_uses_full_master() { + let pp = [3; PUBLIC_PARAM_LEN]; + let master = [7; MASTER_SECRET_LEN]; + let pos = Pos::new(2, 5, 6); + let ots = ots_secret(&pp, &master, pos, 4); + let (fts, _) = fts_open(&pp, &master, 5, &[0; K]); + for byte in 0..MASTER_SECRET_LEN { + let mut changed = master; + changed[byte] ^= 1; + assert_ne!(ots_secret(&pp, &changed, pos, 4), ots); + assert_ne!(fts_open(&pp, &changed, 5, &[0; K]).0, fts); + } +} + /// The split between the two entry points: the seed alone determines the key, /// and the rng one draws a fresh seed per call rather than a fixed one. #[test] diff --git a/crates/xmss/src/hash.rs b/crates/xmss/src/hash.rs index 89d4d9f4f..ffaeae847 100644 --- a/crates/xmss/src/hash.rs +++ b/crates/xmss/src/hash.rs @@ -13,11 +13,17 @@ use crate::*; -// Tweak types (tweak byte 0), so distinct kinds of hashes cannot alias. -pub const TWEAK_TYPE_CHAIN: u8 = 0; -pub const TWEAK_TYPE_WOTS_PK: u8 = 1; -pub const TWEAK_TYPE_MERKLE: u8 = 2; -pub const TWEAK_TYPE_ENCODING: u8 = 3; +pub const PROTOCOL_DOMAIN_SEP: u8 = 0; + +// Tweak types (byte 1). +pub const TWEAK_TYPE_PRF: u8 = 0; +pub const TWEAK_TYPE_CHAIN: u8 = 1; +pub const TWEAK_TYPE_WOTS_PK: u8 = 2; +pub const TWEAK_TYPE_MERKLE: u8 = 3; +pub const TWEAK_TYPE_ENCODING: u8 = 4; +pub const TWEAK_TYPE_PARAMETER: u8 = 10; +pub const TWEAK_TYPE_FILLER: u8 = 11; +pub const TWEAK_TYPE_RANDOMIZER: u8 = 12; pub const TWEAK_LEN: usize = 16; pub type Tweak = [u8; TWEAK_LEN]; @@ -25,14 +31,16 @@ pub type Tweak = [u8; TWEAK_LEN]; /// A full 32-byte BLAKE2s chaining value/output. pub const STATE_LEN: usize = 32; -/// `[tweak_type (1) | sub_position (4) | index (4) | zeros (7)]`, little-endian. +/// `[protocol_domain_sep:1 | type:1 | layer:1 | zero:1 | p:4 | tree:4 | index:4]`, little endian. +/// XMSS sets `layer` and `tree` to zero. /// `index` is the epoch (chain / wots_pk / encoding) or the Merkle node index; /// `sub_position` is the chain position or the Merkle level. pub fn make_tweak(tweak_type: u8, sub_position: u32, index: u32) -> Tweak { let mut tweak = [0u8; TWEAK_LEN]; - tweak[0] = tweak_type; - tweak[1..5].copy_from_slice(&sub_position.to_le_bytes()); - tweak[5..9].copy_from_slice(&index.to_le_bytes()); + tweak[0] = PROTOCOL_DOMAIN_SEP; + tweak[1] = tweak_type; + tweak[4..8].copy_from_slice(&sub_position.to_le_bytes()); + tweak[12..16].copy_from_slice(&index.to_le_bytes()); tweak } diff --git a/crates/xmss/src/lib.rs b/crates/xmss/src/lib.rs index b360707da..4ca40dbb5 100644 --- a/crates/xmss/src/lib.rs +++ b/crates/xmss/src/lib.rs @@ -2,8 +2,8 @@ //! The concrete scheme is defined in the [XMSS specification]. //! //! Every hash is standard BLAKE2s of the exact byte string -//! `tweak | pp | payload`, truncated to n = 128 bits. See the `hash` module for the -//! constructions and per-call compression counts. +//! `tweak | pp | payload`. Randomizer derivation retains 192 bits; other calls +//! retain 128 bits. See the `hash` module for the constructions. //! //! [XMSS specification]: https://github.com/leanEthereum/leanVM/releases/download/doc-latest/XMSS.pdf @@ -40,6 +40,8 @@ pub const NUM_CHAIN_HASHES: usize = 99; /// walks fewer chain steps; grinding takes fewer than 2^15 encode attempts on /// average. pub const TARGET_SUM: usize = V * (CHAIN_LENGTH - 1) - NUM_CHAIN_HASHES; // 195 +/// Maximum randomizer trials per signature. +pub const MAX_RANDOMIZER_TRIALS: u64 = 1 << 32; pub const RANDOMNESS_LEN: usize = 24; pub const MESSAGE_LEN: usize = 32; pub const PUBLIC_PARAM_LEN: usize = 16; @@ -48,7 +50,7 @@ pub const PUBLIC_PARAM_LEN: usize = 16; /// Merkle tree height: a key is valid for up to `2^32` epochs. pub const LOG_LIFETIME: usize = 32; -/// When a signature was made. A key may sign at each epoch of its range once. +/// When a signature was made. Each epoch in the key's range may sign only one message. pub type Epoch = u32; /// Serialized sizes (exact under bincode: fixed arrays, no length prefixes). diff --git a/crates/xmss/src/wots.rs b/crates/xmss/src/wots.rs index eed8a491a..a3d8cc6af 100644 --- a/crates/xmss/src/wots.rs +++ b/crates/xmss/src/wots.rs @@ -1,6 +1,5 @@ //! WOTS (Winternitz one-time signature) with target-sum encoding. -use rand::{CryptoRng, Rng}; use serde::{Deserialize, Serialize}; use crate::*; @@ -106,16 +105,15 @@ pub fn find_randomness_for_wots_encoding( message: &Message, epoch: Epoch, public_param: &PublicParam, - rng: &mut impl CryptoRng, -) -> (Randomness, [u8; V], usize) { - let mut num_iters = 0; - loop { - num_iters += 1; - let randomness: Randomness = rng.random(); - if let Some(encoding) = wots_encode(message, epoch, public_param, &randomness) { - return (randomness, encoding, num_iters); - } - } + seed: &[u8; 32], +) -> Option<(Randomness, [u8; V], u64)> { + (0..MAX_RANDOMIZER_TRIALS).find_map(|trial| { + let mut hasher = primitives::hash::Hasher::new(); + hasher.update(&make_tweak(TWEAK_TYPE_RANDOMIZER, trial as u32, epoch)); + hasher.update(public_param).update(seed).update(message); + let randomness = hasher.finalize()[..RANDOMNESS_LEN].try_into().unwrap(); + wots_encode(message, epoch, public_param, &randomness).map(|encoding| (randomness, encoding, trial + 1)) + }) } /// The target-sum encoding. `D = MD(msg | randomness | zeros)` under the diff --git a/crates/xmss/src/xmss.rs b/crates/xmss/src/xmss.rs index aaca3b313..a75e4e90d 100644 --- a/crates/xmss/src/xmss.rs +++ b/crates/xmss/src/xmss.rs @@ -55,32 +55,17 @@ impl XmssPublicKey { } } -// PRF domains for secret derivation and filler nodes. -const PRF_DOMAINSEP_WOTS_SECRET_KEY: u32 = 1000; -const PRF_DOMAINSEP_PUBLIC_PARAM: u32 = 1001; -const PRF_DOMAINSEP_RANDOM_NODE: u32 = 1002; - -fn prf(seed: &[u8; 32], domain: u32, a: u64, b: u64) -> Digest { - let mut msg = [0u8; 20]; - msg[..4].copy_from_slice(&domain.to_le_bytes()); - msg[4..12].copy_from_slice(&a.to_le_bytes()); - msg[12..20].copy_from_slice(&b.to_le_bytes()); - primitives::hash::keyed_hash(seed, &msg)[..DIGEST_LEN] - .try_into() - .unwrap() -} - -fn gen_wots_secret_key(seed: &[u8; 32], epoch: Epoch) -> WotsSecretKey { - let pre_images = std::array::from_fn(|i| prf(seed, PRF_DOMAINSEP_WOTS_SECRET_KEY, epoch as u64, i as u64)); +fn gen_wots_secret_key(seed: &[u8; 32], public_param: &PublicParam, epoch: Epoch) -> WotsSecretKey { + let pre_images = std::array::from_fn(|i| tweak_hash(public_param, TWEAK_TYPE_PRF, i as u32, epoch, seed)); WotsSecretKey::new(pre_images) } fn gen_public_param(seed: &[u8; 32]) -> PublicParam { - prf(seed, PRF_DOMAINSEP_PUBLIC_PARAM, 0, 0) + tweak_hash(&[0; PUBLIC_PARAM_LEN], TWEAK_TYPE_PARAMETER, 0, 0, seed) } -fn gen_random_node(seed: &[u8; 32], level: usize, index: u64) -> Digest { - prf(seed, PRF_DOMAINSEP_RANDOM_NODE, level as u64, index) +fn gen_random_node(seed: &[u8; 32], public_param: &PublicParam, level: usize, index: u64) -> Digest { + tweak_hash(public_param, TWEAK_TYPE_FILLER, level as u32, index as u32, seed) } /// Merkle parent at `level` (1 compression: both children fill one block). @@ -98,7 +83,7 @@ fn merkle_node(public_param: &PublicParam, level: usize, index: u64, left: &Dige fn leaf_layer(seed: &[u8; 32], public_param: &PublicParam, first_epoch: u64, last_epoch: u64) -> Vec { (first_epoch..=last_epoch) .map(|epoch| { - gen_wots_secret_key(seed, epoch as Epoch) + gen_wots_secret_key(seed, public_param, epoch as Epoch) .public_key(public_param, epoch as Epoch) .hash(public_param, epoch as Epoch) }) @@ -130,7 +115,7 @@ fn build_up( if child_index >= first_child && child_index <= last_child { children[(child_index - first_child) as usize] } else { - gen_random_node(seed, level - 1, child_index) + gen_random_node(seed, public_param, level - 1, child_index) } }; merkle_node(public_param, level, index, &child(2 * index), &child(2 * index + 1)) @@ -174,8 +159,7 @@ impl std::fmt::Display for XmssKeyGenError { impl std::error::Error for XmssKeyGenError {} -/// A fresh key pair, able to sign at each epoch of `epoch_start..=epoch_end` -/// once. The seed comes from `rng`, so nothing can regenerate the key. +/// A fresh key pair for `epoch_start..=epoch_end`, with its seed sampled from `rng`. pub fn key_gen( rng: &mut impl CryptoRng, epoch_start: Epoch, @@ -241,32 +225,30 @@ pub fn key_gen_from_seed( #[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)] pub enum XmssSignError { EpochOutOfRange, + NoAdmissibleEncoding, } impl std::fmt::Display for XmssSignError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::EpochOutOfRange => write!(f, "the epoch is outside the key's range"), + Self::NoAdmissibleEncoding => write!(f, "no admissible encoding within the randomizer trial limit"), } } } impl std::error::Error for XmssSignError {} -/// WARNING: XMSS is a stateful signature scheme, never sign twice with the same -/// `epoch`. (Even signing the same message twice at the same epoch is insecure, -/// because the signature randomness is drawn fresh.) -pub fn sign( - rng: &mut impl CryptoRng, - secret_key: &XmssSecretKey, - message: &Message, - epoch: Epoch, -) -> Result { +/// Never use the same key and epoch to sign two different messages. +/// Signing is deterministic. +pub fn sign(secret_key: &XmssSecretKey, message: &Message, epoch: Epoch) -> Result { if epoch < secret_key.epoch_start || epoch > secret_key.epoch_end { return Err(XmssSignError::EpochOutOfRange); } - let (randomness, encoding, _) = find_randomness_for_wots_encoding(message, epoch, &secret_key.public_param, rng); - let wots_secret_key = gen_wots_secret_key(&secret_key.seed, epoch); + let (randomness, encoding, _) = + find_randomness_for_wots_encoding(message, epoch, &secret_key.public_param, &secret_key.seed) + .ok_or(XmssSignError::NoAdmissibleEncoding)?; + let wots_secret_key = gen_wots_secret_key(&secret_key.seed, &secret_key.public_param, epoch); let wots_signature = wots_secret_key.sign(&encoding, randomness, epoch, &secret_key.public_param); let cache = secret_key.cached_bottom_subtree(epoch); @@ -283,8 +265,7 @@ pub fn sign( } impl XmssSecretKey { - /// The epochs this key can sign at. XMSS forbids signing twice at one, so - /// the caller has to track which of these it has spent. + /// The epochs this key can sign at. The caller must ensure each epoch signs only one message. pub fn epoch_range(&self) -> std::ops::RangeInclusive { self.epoch_start..=self.epoch_end } @@ -351,7 +332,7 @@ impl XmssSecretKey { if neighbour_index >= first_node && neighbour_index <= (last_epoch >> level) { layers[level - level_base][(neighbour_index - first_node) as usize] } else { - gen_random_node(&self.seed, level, neighbour_index) + gen_random_node(&self.seed, &self.public_param, level, neighbour_index) } } } diff --git a/crates/xmss/tests/xmss_tests.rs b/crates/xmss/tests/xmss_tests.rs index 38c81a718..da12fbcc4 100644 --- a/crates/xmss/tests/xmss_tests.rs +++ b/crates/xmss/tests/xmss_tests.rs @@ -12,8 +12,9 @@ fn keygen_sign_verify() { for epoch in [0u32, 1234, u32::MAX] { let (sk, pk) = key_gen_from_seed(seed, epoch.saturating_sub(1), epoch.saturating_add(2)).unwrap(); - let sig = sign(&mut StdRng::seed_from_u64(epoch as u64), &sk, &message, epoch).unwrap(); + let sig = sign(&sk, &message, epoch).unwrap(); verify(&pk, &message, &sig, epoch).unwrap(); + assert_eq!(sign(&sk, &message, epoch).unwrap(), sig); } } @@ -24,7 +25,7 @@ fn serialize_deserialize_and_size() { let epoch = 110; let (sk, pk) = key_gen_from_seed(seed, 100, 115).unwrap(); - let sig = sign(&mut StdRng::seed_from_u64(0), &sk, &message, epoch).unwrap(); + let sig = sign(&sk, &message, epoch).unwrap(); let public_key_bytes = bincode::serialize(&pk).unwrap(); assert_eq!(public_key_bytes.len(), PUB_KEY_SIZE); @@ -50,26 +51,6 @@ fn deterministic_keygen_and_range_separation() { assert_ne!(pk.merkle_root, longer_range.merkle_root); } -/// Pin the wire layout of the tweak: the type byte, both little-endian `u32` -/// fields, and the seven trailing zeros. Literal bytes, so an endianness -/// mistake cannot be mirrored here. -#[test] -fn tweak_layout_is_exact() { - assert_eq!( - [ - TWEAK_TYPE_CHAIN, - TWEAK_TYPE_WOTS_PK, - TWEAK_TYPE_MERKLE, - TWEAK_TYPE_ENCODING - ], - [0, 1, 2, 3] - ); - assert_eq!( - make_tweak(TWEAK_TYPE_MERKLE, 0x0102_0304, 0xa0b0_c0d0), - [2, 0x04, 0x03, 0x02, 0x01, 0xd0, 0xc0, 0xb0, 0xa0, 0, 0, 0, 0, 0, 0, 0] - ); -} - #[test] fn tweak_separates_hash_domains() { let pp = [7u8; PUBLIC_PARAM_LEN]; @@ -115,7 +96,7 @@ fn tampered_signatures_rejected() { let message = test_message(); let epoch = 7; let (sk, pk) = key_gen_from_seed(seed, 0, 15).unwrap(); - let sig = sign(&mut StdRng::seed_from_u64(1), &sk, &message, epoch).unwrap(); + let sig = sign(&sk, &message, epoch).unwrap(); verify(&pk, &message, &sig, epoch).unwrap(); let mut bad_message = message; @@ -139,10 +120,7 @@ fn tampered_signatures_rejected() { Err(XmssVerifyError::InvalidMerklePath) ); - assert_eq!( - sign(&mut StdRng::seed_from_u64(2), &sk, &message, 16), - Err(XmssSignError::EpochOutOfRange) - ); + assert_eq!(sign(&sk, &message, 16), Err(XmssSignError::EpochOutOfRange)); } /// Detect changes to the encoding predicate through its grinding cost. @@ -151,11 +129,11 @@ fn tampered_signatures_rejected() { fn encoding_grinding_bits() { let n = 200; let pp = [0u8; PUBLIC_PARAM_LEN]; - let mut total_iters = 0usize; + let mut total_iters = 0u64; for i in 0..n { let mut rng = StdRng::seed_from_u64(i as u64); let message: Message = rng.random(); - let (_, _, num_iters) = find_randomness_for_wots_encoding(&message, i as u32, &pp, &mut rng); + let (_, _, num_iters) = find_randomness_for_wots_encoding(&message, i as u32, &pp, &rng.random()).unwrap(); total_iters += num_iters; } let bits = (total_iters as f64 / n as f64).log2(); @@ -175,13 +153,10 @@ fn secret_key_survives_a_round_trip() { assert_eq!(reloaded.epoch_range(), 40..=45); let message = test_message(); for epoch in [40, 43, 45] { - let sig = sign(&mut StdRng::seed_from_u64(epoch), &reloaded, &message, epoch as u32).unwrap(); + let sig = sign(&reloaded, &message, epoch as u32).unwrap(); verify(&pk, &message, &sig, epoch as u32).unwrap(); } - assert_eq!( - sign(&mut StdRng::seed_from_u64(0), &reloaded, &message, 46), - Err(XmssSignError::EpochOutOfRange) - ); + assert_eq!(sign(&reloaded, &message, 46), Err(XmssSignError::EpochOutOfRange)); } /// The SSZ encoding is the container's fields concatenated, in declaration @@ -193,7 +168,7 @@ fn ssz_layout_is_exact() { let message = test_message(); let epoch = 300; let (sk, pk) = key_gen_from_seed(seed, 290, 310).unwrap(); - let sig = sign(&mut StdRng::seed_from_u64(4), &sk, &message, epoch).unwrap(); + let sig = sign(&sk, &message, epoch).unwrap(); let mut expected_pk = Vec::new(); expected_pk.extend_from_slice(&pk.merkle_root); @@ -251,15 +226,15 @@ fn prepare_warms_without_changing_signatures() { // 0 and 200 are far enough apart to land in different bottom subtrees. sk.prepare(200).unwrap(); - let after_prepare = sign(&mut StdRng::seed_from_u64(6), &sk, &message, 200).unwrap(); + let after_prepare = sign(&sk, &message, 200).unwrap(); verify(&pk, &message, &after_prepare, 200).unwrap(); - let fresh = sign(&mut StdRng::seed_from_u64(6), &sk, &message, 200).unwrap(); + let fresh = sign(&sk, &message, 200).unwrap(); assert_eq!(after_prepare, fresh); // A miss on the warmed subtree rebuilds rather than reusing it. sk.prepare(0).unwrap(); - let other = sign(&mut StdRng::seed_from_u64(7), &sk, &message, 0).unwrap(); + let other = sign(&sk, &message, 0).unwrap(); verify(&pk, &message, &other, 0).unwrap(); assert_eq!(sk.prepare(256), Err(XmssSignError::EpochOutOfRange)); @@ -273,7 +248,7 @@ fn key_gen_draws_a_usable_seed() { let message = test_message(); let (sk, pk) = key_gen(&mut rng, 70, 80).unwrap(); assert_eq!(sk.epoch_range(), 70..=80); - let sig = sign(&mut rng, &sk, &message, 75).unwrap(); + let sig = sign(&sk, &message, 75).unwrap(); verify(&pk, &message, &sig, 75).unwrap(); // A fresh draw is a different key. diff --git a/doc/sphincs/main.tex b/doc/sphincs/main.tex index 60535f9d8..f4c71e345 100644 --- a/doc/sphincs/main.tex +++ b/doc/sphincs/main.tex @@ -11,11 +11,10 @@ \usepackage{xcolor} \usepackage[colorlinks=true,linkcolor=blue!50!black,citecolor=blue!50!black,urlcolor=blue!50!black]{hyperref} -\theoremstyle{definition} -\newtheorem{definition}{Definition}[section] \theoremstyle{plain} -\theoremstyle{remark} -\newtheorem{remark}[definition]{Remark} +\newtheorem{theorem}{Theorem}[section] +\theoremstyle{definition} +\newtheorem{definition}[theorem]{Definition} \newcommand{\bits}[1]{\{0,1\}^{#1}} \newcommand{\getsr}{\stackrel{\$}{\gets}} @@ -25,8 +24,6 @@ \newcommand{\Gen}{\mathsf{Gen}} \newcommand{\Sig}{\mathsf{Sig}} \newcommand{\Ver}{\mathsf{Ver}} -\newcommand{\SIG}{\mathsf{SIG}} -\newcommand{\Chain}{\mathsf{Chain}} \newcommand{\hash}{\mathsf{H}} \newcommand{\LE}{\mathsf{LE}} \newcommand{\Truncate}{\mathsf{Truncate}} @@ -35,11 +32,6 @@ \newcommand{\pk}{\mathit{pk}} \newcommand{\rootnode}{\mathit{root}} \newcommand{\tw}{\mathit{tw}} -\newcommand{\lmsg}{\ell_{\mathrm{msg}}} -\newcommand{\lpar}{\ell_{\mathrm{p}}} -\newcommand{\ltwk}{\ell_{\mathrm{t}}} -\newcommand{\lrnd}{\ell_{\mathrm{rnd}}} -\newcommand{\lctr}{\ell_{\mathrm{c}}} \newcommand{\qs}{q_{\mathrm{s}}} \newcommand{\amax}{A_{\max}} \newcommand{\cmax}{C_{\max}} @@ -55,8 +47,9 @@ \newcommand{\FtsRec}{\mathsf{Fts.recover}} \emergencystretch=1.5em +\setlist[enumerate]{leftmargin=2em, itemsep=4pt} -\title{Example of a SPHINCS$^+$ variant} +\title{A SPHINCS$^+$ variant} \author{} \date{} @@ -65,397 +58,410 @@ \begin{abstract} -We present, as an example, a SPHINCS$^+$-based signature with the following properties: +This specification defines a SPHINCS$^+$ variant with a lifetime of $2^{24}$ signatures per key pair: \begin{itemize} - \item \textbf{stateless}: supporting up to $2^{24}$ signatures. - \item \textbf{NIST security level~1}~\cite{NISTPQC} (TODO prove it) - \item \textbf{public key: 32 bytes}. - \item \textbf{signature: 4924 bytes}. - \item \textbf{497 hashes per verification}. - \item signing costs 190K hashes with 1024 bytes of cached signer state, or 1.55M without. - \item \textbf{key generation costs 1.38M hashes}. + \item \textbf{NIST security level~1}~\cite{NISTPQC}, $\approx 128$-bit (resp. $\approx 64$-bit) of classical (resp. quantum) security, in the Random Oracle model, ROM (resp. Quantum Random Oracle model, QROM).\footnote{NIST stands for National Institute of Standards and Technology. The classical bound is proved (Section~\ref{sec:security}); the quantum one is a target, not a proved statement (Section~\ref{sec:quantum}).} + \item \textbf{Public key: 32 bytes.} + \item \textbf{Signature: 4924 bytes.} + \item \textbf{Verification: 497 hashes.} + \item \textbf{Key generation: 1.38M hashes.} + \item \textbf{Signing: approximately 190K hashes on average}, with a 1024-byte public cache. \end{itemize} \end{abstract} -The construction is SPHINCS$^+$~\cite{SPHINCSPLUS,FIPS205} with two of the optimizations surveyed in~\cite{KN25}, WOTS$^+$C and FORS$^+$C, both from~\cite{HK22C}; its third, PORS$^+$FP, is not used. +The construction uses compressed variants of the Winternitz one-time signature (WOTS) and forest of random subsets (FORS), called WOTS$^+$C and FORS$^+$C~\cite{HK22C,KN25}. They are combined within the SPHINCS$^+$ framework~\cite{SPHINCSPLUS}. The definitions below specify the concrete variant completely. -\section{Definitions and notation} +\section{Hashing and parameters} +\label{sec:parameters} -\begin{definition}[Signature scheme] -A signature scheme is a tuple $\SIG=(\Gen,\Sig,\Ver)$, where $\Gen$ and $\Sig$ are randomized and $\Ver$ is deterministic: +Write $\bits r$ for $r$-bit strings, $\concat$ for concatenation, and $\LE_r(z)$ for the unsigned $r$-bit little-endian encoding of $z$. Write $x\getsr A$ for a uniform sample from $A$. Indices and bit positions start at zero; $\bot$ denotes failure. + +Let $\hash:\bits{*}\to\bits{256}$ be the hash function. Most operations use its first $n=128$ output bits: \[ - \Gen\longrightarrow(\pk,\sk),\qquad - \Sig(\sk,m)\longrightarrow\sigma\in\Sigma\cup\{\bot\},\qquad - \Ver(\pk,m,\sigma)\longrightarrow\{0,1\}, + \Th(P,\tw,M)=\Truncate_n\!\left(\hash(\tw\concat P\concat M)\right). \] -where $\Sigma$ is the signature space and $m\in\bits{\lmsg}$. Whenever $(\pk,\sk)$ is output by $\Gen$ and $\Sig(\sk,m)$ returns $\sigma\neq\bot$, correctness requires $\Ver(\pk,m,\sigma)=1$. $\Sig$ keeps no state and may be called on any message any number of times, but security degrades with that number: this specification is stated for at most $\qs$ signatures per key pair. -\end{definition} +Here $P$ is a 128-bit public parameter and $\tw$ is a 128-bit \emph{tweak}: an address identifying the operation and its position in the construction. Appendix~\ref{sec:tweaks} gives every tweak's exact bytes. $\Truncate_r$ always keeps the first $r$ bits, with bits read least significant first within each byte. -Byte strings are concatenated with $\concat$. Bits and integer encodings are little endian. $\LE_r(a)$ is the unsigned $r$-bit encoding of $a$. All indices are zero based. Layers are numbered from the top: layer $0$ carries the public key, layer $d-1$ signs few-time keys. +The message $m$ and master secret $S$ are each 256 bits. WOTS signs 128-bit values $M$. The signature contains a 128-bit randomizer $\rho$ and one 32-bit counter $c$ per WOTS signature. Derived signing secrets, chain values and Merkle nodes are 128 bits; write $\mathcal H=\bits n$. \begin{center} \begin{tabular}{@{}lll@{}} \toprule Symbol & Value & Meaning\\ \midrule -$n$ & $128$ bits & hash value and Merkle node length\\ -$\lpar$ & $128$ bits & public parameter length\\ -$\ltwk$ & $128$ bits & tweak length\\ -$\lmsg$ & $256$ bits & message length\\ -$\lrnd$ & $128$ bits & randomizer length\\ -$\lctr$ & $32$ bits & encoding counter length\\ -$w$ & $3$ & chunk size in bits\\ -$v$ & $42$ & code length\\ -$T$ & $191$ & target sum\\ -$d$ & $3$ & hypertree layers\\ -$(h_0,h_1,h_2)$ & $(12,7,7)$ & Merkle tree height of each layer\\ -$h$ & $26$ & total height, $h=\sum_\lay h_\lay$\\ -$a$ & $10$ & $\log_2$ of the leaves in one few-time tree\\ -$k$ & $15$ & digest index groups; the forest holds $k-1$ trees\\ -$\qs$ & $2^{24}$ & signatures per key pair\\ -$\amax$ & $2^{32}$ & maximum digest attempts per signature\\ -$\cmax$ & $2^{32}$ & maximum encoding attempts per layer\\ +$w$ & $3$ & bit-size of WOTS chain positions\\ +$v$ & $42$ & chains per WOTS key\\ +$T$ & $191$ & sum of the signed chain positions\\ +$d$ & $3$ & layers, numbered from the top\\ +$(h_0,h_1,h_2)$ & $(12,7,7)$ & tree heights at those layers\\ +$h$ & $26$ & total height, $h=h_0+h_1+h_2$\\ +$a$ & $10$ & height of each FORS tree\\ +$k$ & $15$ & digest indices, of which $k-1$ open trees\\ +$\qs$ & $2^{24}$ & signing requests allowed per key pair\\ +$\amax$ & $2^{32}$ & maximum randomizer trials per signature\\ +$\cmax$ & $2^{32}$ & maximum encoding attempts per WOTS signature\\ \bottomrule \end{tabular} \end{center} -Let $\hash:\bits{*}\to\bits{256}$ be a cryptographic hash function, and let $\Truncate_\nu$ keep the first $\nu$ bits of its output. The tweakable hash $\Th:\mathcal P\times\mathcal T\times\mathcal M\to\mathcal H$, with $\mathcal P=\bits{\lpar}$, $\mathcal T=\bits{\ltwk}$, $\mathcal M=\bits{*}$ and $\mathcal H=\bits{n}$, is +The public parameter and all signing secrets are derived from $S$. In particular, \[ - \Th(P,\tw,M)=\Truncate_n\!\left(\hash(\tw\concat P\concat M)\right). + P=\Th\!\left(0^{128},\mathsf{tw}_{\mathrm{parameter}},S\right). \] -$\hash$ and the code $\mathcal C$ of Section~\ref{sec:ots} are those of~\cite{leanVM}, with a different target sum. +The component definitions below derive signing secrets as needed from this fixed $S$. Functions that read secrets use $S$ implicitly. -\begin{definition}[Tweak encoding] -For one-byte $t$ and $\lay$, and unsigned 32-bit integers $\tau$, $p$ and $j$, define the 16-byte tweak -\[ - \mathsf{enc}(t,\lay,\tau,p,j)=\LE_8(t)\concat\LE_8(\lay)\concat\LE_{32}(\tau)\concat\LE_{32}(p)\concat\LE_{32}(j)\concat\LE_{16}(0), -\] -fourteen bytes of fields and two of padding. The byte-wide fields cap $d\leq256$ and $k\leq257$; the 32-bit fields are never near their range here. -\end{definition} +\section{WOTS: signing with hash chains} +\label{sec:ots} -A tweak names one hash call in the whole structure, which is what lets a security argument treat each call separately. Inside the hypertree, $\lay$ is the layer and $\tau$ the tree within it; inside a few-time key, $\lay$ is the tree in the forest and $\tau$ the index $\idx$ that selects the instance. Define +A WOTS key is identified by a layer $\lay$, a tree $\tau$ and a leaf $e$. It contains $v=42$ chains, each with eight positions, numbered $0$ through $7$. Fix such a key. For $0\leq i\lay}h_j}\right\rfloor\bmod 2^{h_\lay}. + D=\Th\!\left(P,\mathsf{tw}_{\mathrm{enc}}(\lay,\tau,e),M\concat\LE_{32}(c)\right). \] -With $(h_0,h_1,h_2)=(12,7,7)$ the divisors are $2^{26},2^{14},2^{7}$ for $\tau$ and $2^{14},2^{7},2^{0}$ for $e$. Layer $0$ has $\tau_0=0$, its single tree being the public key, and layer $d-1$ has $e_{d-1}=\idx\bmod2^{h_{d-1}}$. The layers link through the same two functions, +Interpret the first and last eight bytes of $D$ as little-endian integers $d_0,d_1$. Each supplies 21 consecutive three-bit positions: \[ - \tau_\lay=\tau_{\lay-1}\cdot2^{h_{\lay-1}}+e_{\lay-1}, + x_{21q+r}=\left\lfloor d_q/2^{3r}\right\rfloor\bmod 8, + \qquad q\in\{0,1\},\quad 0\leq r<21. \] -so the tree used on layer $\lay$ is the one whose root sits at leaf $e_{\lay-1}$ of the tree used on layer $\lay-1$. Layer $\lay$ holds $2^{\sum_{j<\lay}h_j}$ trees of $2^{h_\lay}$ leaves, so $(\tau_\lay,e_\lay)$ takes $2^{\sum_{j\leq\lay}h_j}$ values, that is $2^{12}$, $2^{19}$ and $2^{26}$ here, the last putting the $2^h$ indices in bijection with the leaves of the bottom layer. +Return $x$ if bit 63 of each $d_q$ is zero and $\sum_i x_i=T$; otherwise return $\bot$. The two zero bits ensure that an accepted encoding specifies all 128 bits of $D$. -\section{The one-time signature} -\label{sec:ots} +\paragraph{Signing.} +$\OtsSign(P,\lay,\tau,e,M)$ tries $c=0,\ldots,\cmax-1$ in order. At the first successful encoding $x$, it returns +\[ + (c,\sigma),\qquad \sigma_i=C_{i,x_i},\quad 0\leq i\lay}h_j}\right\rfloor\bmod 2^{h_\lay}. \] - -\begin{definition}[$\TreeRoot$, $\TreePath$] -$\TreeRoot(P,\lay,\tau)=X^{\lay,\tau}_{h_\lay,0}$, and $\TreePath(P,\lay,\tau,e)=(A_0,\ldots,A_{h_\lay-1})$ with +Concretely, $e_0$ is the high 12 bits of $\idx$, $e_1$ the next 7 bits, and $e_2$ the low 7 bits. Then \[ - A_\lambda=X^{\lay,\tau}_{\lambda,\lfloor e/2^\lambda\rfloor\oplus1}. + \tau_0=0,\qquad \tau_1=e_0,\qquad \tau_2=2^7e_0+e_1. \] -\end{definition} +Thus the key at $(0,0,e_0)$ signs tree $(1,\tau_1)$'s root, the key at $(1,\tau_1,e_1)$ signs tree $(2,\tau_2)$'s root, and the key at $(2,\tau_2,e_2)$ signs $\FtsKey(P,\idx)$. -\begin{definition}[$\TreeFold$] -$\TreeFold(P,\lay,\tau,e,X,A)$ sets $V_0=X$ and, for $0\leq\lambda