confium_tc_frost_ed25519/transcript.rs
1//! Fiat-Shamir transcript for FROST-ed25519.
2//!
3//! The FROST spec (draft-irtf-cfrg-frost, §4.2 "Palette of operations")
4//! names five domain-separated hash functions H1–H5 over a single
5//! underlying hash H. For the ed25519 instantiation the underlying hash
6//! is SHA-512 and the domain separator is the ASCII string
7//! `"FROST-ed25519-SHA512-v1"` prefixed into every input.
8//!
9//! The five functions map onto the protocol's needs as follows:
10//!
11//! | fn | role |
12//! |----|------|
13//! | H1 | nonce derivation —rho binding factor input|
14//! | H2 | challenge scalar `c` (this MUST equal the ed25519 challenge
15//! `SHA-512(R ‖ A ‖ M)` reduced mod ℓ, so the signature verifies
16//! under any standard ed25519 verifier) |
17//! | H3 | nonce randomness extraction |
18//! | H4 | variable-length equality-check listings |
19//! | H5 | variable-length equality-check (alternate) |
20//!
21//! For the signing scheme implemented here we only need H1 (binding
22//! factor), H3 (nonce seed from `(secret, nonce_seed, msg)`), and the
23//! bare ed25519 challenge (H2). H4 / H5 are unused in 2-of-N signing
24//! without the optional pre-process round; they are included so future
25//! extensions match the spec's H_n palette.
26
27use curve25519_dalek::scalar::Scalar;
28use sha2::{Digest, Sha512};
29
30use crate::group;
31
32/// Domain separator prefix used by every H1–H5 invocation. Matches the
33/// ciphersuite identifier in draft-irtf-cfrg-frost §7.3 ("FROST(ed25519,
34/// SHA-512)").
35pub const DOMAIN: &[u8] = b"FROST-ed25519-SHA512-v1";
36
37/// H1 — the binding factor `rho`. Output: a scalar mod ℓ.
38///
39/// The spec encodes a structured input (a "rho input" prefix). For our
40/// signing scheme the binding factor input is the concatenation of the
41/// message and the sorted list of `(party_index, D_i, E_i)` commitments.
42pub fn h1_binding_factor(rho_input: &[u8]) -> Scalar {
43 let mut h = Sha512::new();
44 h.update(DOMAIN);
45 h.update(b"rho");
46 h.update(rho_input);
47 let digest = h.finalize();
48 let mut wide = [0u8; 64];
49 wide.copy_from_slice(&digest);
50 Scalar::from_bytes_mod_order_wide(&wide)
51}
52
53/// H3 — nonce randomness. Maps a 32-byte seed + message to a scalar mod ℓ.
54pub fn h3_nonce(seed: &[u8], msg: &[u8]) -> Scalar {
55 let mut h = Sha512::new();
56 h.update(DOMAIN);
57 h.update(b"nonce");
58 h.update(seed);
59 h.update(msg);
60 let digest = h.finalize();
61 let mut wide = [0u8; 64];
62 wide.copy_from_slice(&digest);
63 Scalar::from_bytes_mod_order_wide(&wide)
64}
65
66/// The ed25519 challenge scalar `c = SHA-512(R ‖ A ‖ M)` reduced mod ℓ.
67///
68/// This deliberately does NOT carry the FROST domain separator — the
69/// whole point is to produce a signature that any RFC-8032 verifier
70/// accepts. The only prefix is the implicit one baked into SHA-512 of
71/// `R || A || M`, exactly as RFC 8032 §5.1.7 prescribes.
72pub fn challenge(r_bytes: &[u8; 32], a_bytes: &[u8; 32], msg: &[u8]) -> Scalar {
73 let mut h = Sha512::new();
74 h.update(r_bytes);
75 h.update(a_bytes);
76 h.update(msg);
77 let digest = h.finalize();
78 let mut wide = [0u8; 64];
79 wide.copy_from_slice(&digest);
80 Scalar::from_bytes_mod_order_wide(&wide)
81}
82
83/// Build the "rho input" for a signing instance — the canonical bytes that
84/// all participating parties hash to derive each `rho_i`.
85///
86/// Format: `msg_len:u32 BE | msg | party_count:u32 BE | for each party
87/// (sorted by index): idx:u32 BE | D_i | E_i`.
88pub fn rho_input(
89 msg: &[u8],
90 commitments: &[(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])],
91) -> Vec<u8> {
92 let mut out = Vec::new();
93 out.extend_from_slice(&(msg.len() as u32).to_be_bytes());
94 out.extend_from_slice(msg);
95 out.extend_from_slice(&(commitments.len() as u32).to_be_bytes());
96 let mut sorted = commitments.to_vec();
97 sorted.sort_by_key(|t| t.0);
98 for (idx, d, e) in &sorted {
99 out.extend_from_slice(&idx.to_be_bytes());
100 out.extend_from_slice(d);
101 out.extend_from_slice(e);
102 }
103 out
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use curve25519_dalek::scalar::Scalar;
110
111 #[test]
112 fn h_functions_produce_scalar() {
113 let _ = h1_binding_factor(b"abc");
114 let _ = h3_nonce(b"seed-32-bytes________________!", b"msg");
115 }
116
117 #[test]
118 fn challenge_matches_ed25519_definition() {
119 // The challenge must equal SHA-512(R || A || M) mod ℓ.
120 let r = [1u8; 32];
121 let a = [2u8; 32];
122 let msg = b"hello";
123 let c = challenge(&r, &a, msg);
124 // Recompute independently.
125 let mut h = Sha512::new();
126 h.update(r);
127 h.update(a);
128 h.update(msg);
129 let digest = h.finalize();
130 let mut wide = [0u8; 64];
131 wide.copy_from_slice(&digest);
132 let expected = Scalar::from_bytes_mod_order_wide(&wide);
133 assert_eq!(c, expected);
134 }
135
136 #[test]
137 fn rho_input_sorts_parties() {
138 let cs = vec![
139 (3u32, [3u8; 32], [13u8; 32]),
140 (1, [1u8; 32], [11u8; 32]),
141 (2, [2u8; 32], [12u8; 32]),
142 ];
143 let out = rho_input(b"m", &cs);
144 // The first party index after the header should be 1, not 3.
145 // Header: 4 (msg len) + 1 (msg) + 4 (party count) = 9 bytes.
146 let idx_bytes: [u8; 4] = out[9..13].try_into().unwrap();
147 assert_eq!(u32::from_be_bytes(idx_bytes), 1);
148 let idx_bytes2: [u8; 4] = out[9 + 4 + 64..9 + 4 + 64 + 4].try_into().unwrap();
149 assert_eq!(u32::from_be_bytes(idx_bytes2), 2);
150 }
151
152 /// Known-answer vector for `challenge()` with all-`0x42` R, all-`0x43`
153 /// A, and the message `b"frost-kat"`. The expected scalar is the
154 /// little-endian byte encoding of `SHA-512(R || A || M)` reduced mod
155 /// ℓ. This pins the Fiat-Shamir transcript so a future refactor that
156 /// accidentally changes the challenge derivation will fail loudly.
157 #[test]
158 fn challenge_known_answer_hex_vector() {
159 let r = [0x42u8; 32];
160 let a = [0x43u8; 32];
161 let msg = b"frost-kat";
162 let c = challenge(&r, &a, msg);
163 let hex_actual = hex::encode(c.to_bytes());
164 // Pinned: SHA-512(0x42^32 || 0x43^32 || b"frost-kat") mod ℓ,
165 // little-endian. Regenerate by running the script in the test's
166 // docstring if any input changes.
167 let pinned = "e099cbd6aa693ad425eaa910e8346501caa0ed6fed30ad3a3dc378da61c0dc0c";
168 assert_eq!(hex_actual, pinned);
169 assert_eq!(
170 hex_actual.len(),
171 64,
172 "scalar encodes to 32 bytes / 64 hex chars"
173 );
174 }
175}