Skip to main content

confium_tc_frost_ed25519/
sign.rs

1//! FROST threshold signing over ed25519.
2//!
3//! Implements the FROST signing protocol (draft-irtf-cfrg-frost §4 + the
4//! ed25519 ciphersuite) so that the produced signature `(R, z)` is a
5//! standard RFC-8032 ed25519 signature verifiable by any conformant
6//! verifier (e.g. `ed25519-dalek`, libsodium, Go `crypto/ed25519`).
7//!
8//! ## Protocol
9//!
10//! Three rounds, each producing the same final signature on every party:
11//!
12//! - **Round 1** — each party generates a nonce pair `(d_i, e_i)` and
13//!   broadcasts its hiding / binding commitments `D_i = d_i·B`,
14//!   `E_i = e_i·B`. No incoming messages.
15//!
16//! - **Round 2** — each party receives all commitments, computes the
17//!   per-party binding factor `ρ_i`, the group commitment
18//!   `R = Σ_i (D_i + ρ_i·E_i)`, and the challenge
19//!   `c = SHA-512(R ‖ A ‖ M) mod ℓ`. Each party then emits its share
20//!   response `z_i = d_i + ρ_i·e_i + λ_i·s_i·c` where `λ_i` is the
21//!   Lagrange coefficient over the participating set and `s_i` is this
22//!   party's long-term secret share.
23//!
24//! - **Round 3** — each party receives every other party's `z_i`, verifies
25//!   each against its commitment (proof-of-byzantine detection), and
26//!   aggregates `z = Σ_i z_i`. The final signature is `(R, z)`. The party
27//!   verifies `z·B == R + c·A` before emitting it.
28//!
29//! Because every party observes the same commitment set and computes the
30//! same `R`, `c`, and `λ_i` weights, the aggregated signature is
31//! identical across all parties — exactly what the threshold property
32//! demands.
33//!
34//! ## Deviations
35//!
36//! - **Three rounds, not two.** FROST is a "two-round" protocol in the
37//!   sense that there are two communication rounds (commit, respond). The
38//!   framework's round model treats aggregation as a separate local
39//!   round, so we expose three rounds here. Parties that only need the
40//!   signature from a coordinator could fold round 3 into the
41//!   coordinator's logic; this implementation makes every party an
42//!   aggregator so the test harness can assert cross-party agreement.
43//!
44//! - **Nonce generation is not deterministic.** The spec recommends
45//!   deriving nonces deterministically from `(secret, nonce_seed, msg)`
46//!   via H3; this implementation uses `OsRng` for simplicity. A future
47//!   revision should add the deterministic path so signing sessions are
48//!   reproducible and side-channel-resistant under repeated inputs.
49
50use curve25519_dalek::edwards::EdwardsPoint;
51use curve25519_dalek::rand_core::UnwrapErr;
52use curve25519_dalek::scalar::Scalar;
53use curve25519_dalek::traits::Identity;
54
55use crate::error::{
56    CODE_AGG_VERIFY_FAILED, CODE_BELOW_THRESHOLD, CODE_INVALID_COMMITMENT, CODE_INVALID_SHARE_SIG,
57    CODE_MALFORMED_MESSAGE, CODE_MALFORMED_SHARE, CODE_MISSING_COMMITMENT, CODE_ROSTER_CONFIG,
58    CODE_ROUND_OVERFLOW, CODE_SESSION_NOT_COMPLETE, FrostError, Result,
59};
60use crate::group;
61use crate::polynomial::lagrange_coefficient;
62use crate::transcript;
63
64/// Canonical scheme name advertised through the registry.
65pub const SCHEME_NAME: &str = "FROST-ed25519";
66
67/// Wire tags for the two message types.
68const MSG_ROUND1_COMMIT: u8 = 0x11;
69const MSG_ROUND2_RESPONSE: u8 = 0x12;
70
71// ---------------------------------------------------------------------------
72// Scheme + registration
73// ---------------------------------------------------------------------------
74
75/// FROST-ed25519 threshold signing scheme.
76///
77/// Stateless; per-session state lives in the internal `FrostSession`.
78pub struct FrostEd25519;
79
80impl confium_tc::registry::TcScheme for FrostEd25519 {
81    fn name(&self) -> &'static str {
82        SCHEME_NAME
83    }
84
85    fn kind(&self) -> confium_tc::registry::TcSchemeKind {
86        confium_tc::registry::TcSchemeKind::Signature
87    }
88
89    fn create_session(
90        &self,
91        params: &confium_tc::SessionParams,
92    ) -> confium_tc::error::Result<Box<dyn confium_tc::registry::SessionImpl>> {
93        FrostSession::new(params)
94            .map(|s| Box::new(s) as Box<dyn confium_tc::registry::SessionImpl>)
95            .map_err(FrostError::framework)
96    }
97}
98
99// Register at link time so `Session::create("FROST-ed25519")` resolves.
100confium_tc::register_tc_scheme!(FrostEd25519);
101
102// ---------------------------------------------------------------------------
103// Session
104// ---------------------------------------------------------------------------
105
106/// One party's nonce pair, generated in round 1.
107struct NoncePair {
108    /// Hiding nonce `d`.
109    d: Scalar,
110    /// Binding nonce `e`.
111    e: Scalar,
112    /// Hiding commitment `D = d·B`.
113    d_commit: [u8; group::ELEMENT_BYTES],
114    /// Binding commitment `E = e·B`.
115    e_commit: [u8; group::ELEMENT_BYTES],
116}
117
118impl NoncePair {
119    fn generate() -> Self {
120        let mut rng = UnwrapErr(getrandom::SysRng);
121        let d = Scalar::random(&mut rng);
122        let e = Scalar::random(&mut rng);
123        let d_point = group::mul_base(&d);
124        let e_point = group::mul_base(&e);
125        NoncePair {
126            d,
127            e,
128            d_commit: group::point_to_bytes(&d_point),
129            e_commit: group::point_to_bytes(&e_point),
130        }
131    }
132}
133
134/// A received commitment `(party_index, D_i, E_i)`.
135#[derive(Clone)]
136struct Commitment {
137    party_id: String,
138    idx: u32,
139    d: [u8; group::ELEMENT_BYTES],
140    e: [u8; group::ELEMENT_BYTES],
141}
142
143/// A received share response `(party_index, z_i)`.
144struct ShareResponse {
145    party_id: String,
146    idx: u32,
147    z: Scalar,
148}
149
150struct FrostSession {
151    party_id: String,
152    party_index: u32,
153    threshold: u32,
154    /// This party's long-term secret share.
155    secret_share: Scalar,
156    /// The message to sign.
157    message: Vec<u8>,
158    /// Our nonce pair, generated in round 1. Cleared after round 2.
159    nonce: Option<NoncePair>,
160    /// Commitments we received in round 1 (including our own).
161    commitments: Vec<Commitment>,
162    /// The set of participating indices, populated from incoming
163    /// round-1 commitments. Used to compute Lagrange weights.
164    participants: Vec<u32>,
165    /// Group commitment R and aggregate public key A, computed in round 2.
166    r_point: Option<EdwardsPoint>,
167    r_bytes: Option<[u8; group::ELEMENT_BYTES]>,
168    pubkey_bytes: Option<[u8; group::ELEMENT_BYTES]>,
169    /// Our share response, computed in round 2.
170    our_response: Option<Scalar>,
171    /// Final signature `(R || z)`, computed in round 3.
172    signature: Option<[u8; 64]>,
173    round_done: u8,
174}
175
176impl FrostSession {
177    fn new(params: &confium_tc::SessionParams) -> Result<Self> {
178        let threshold = params.threshold;
179        let roster: Vec<String> = params
180            .parties
181            .parties()
182            .iter()
183            .map(|p| p.id.clone())
184            .collect();
185        let n = roster.len();
186        if threshold == 0 {
187            return Err(FrostError::RosterConfig {
188                reason: "threshold must be >= 1",
189                code: CODE_ROSTER_CONFIG,
190            });
191        }
192        if (threshold as usize) > n {
193            return Err(FrostError::RosterConfig {
194                reason: "threshold exceeds party count",
195                code: CODE_ROSTER_CONFIG,
196            });
197        }
198        let this_idx = params.this_party_idx;
199        if this_idx >= n {
200            return Err(FrostError::RosterConfig {
201                reason: "this_party_idx out of range",
202                code: CODE_ROSTER_CONFIG,
203            });
204        }
205        let party_id = roster[this_idx].clone();
206        let party_index = (this_idx as u32) + 1;
207
208        // The local share must be present and well-formed for a signing
209        // session.
210        let secret_share = params
211            .local_share
212            .as_ref()
213            .ok_or(FrostError::MalformedShare {
214                reason: "signing session requires a local share",
215                code: CODE_MALFORMED_SHARE,
216            })?;
217        // The share payload may either be the raw 32-byte scalar or a
218        // DKG output blob `(pubkey || share)`. Try the blob first; fall
219        // back to raw scalar.
220        let (secret_scalar, pubkey_bytes): (Scalar, Option<[u8; group::ELEMENT_BYTES]>) =
221            if secret_share.bytes().len() == 4 + group::ELEMENT_BYTES + 4 + group::SCALAR_BYTES
222                && crate::dkg::parse_output(secret_share.bytes()).is_ok()
223            {
224                let (pk, share) = crate::dkg::parse_output(secret_share.bytes())
225                    .expect("checked length and parse above");
226                (group::scalar_from_slice(&share)?, Some(pk))
227            } else {
228                (group::scalar_from_slice(secret_share.bytes())?, None)
229            };
230
231        let message = params.message.clone().unwrap_or_default();
232
233        Ok(FrostSession {
234            party_id,
235            party_index,
236            threshold,
237            secret_share: secret_scalar,
238            message,
239            nonce: None,
240            commitments: Vec::new(),
241            participants: Vec::new(),
242            r_point: None,
243            r_bytes: None,
244            pubkey_bytes,
245            our_response: None,
246            signature: None,
247            round_done: 0,
248        })
249    }
250
251    /// Round 1 — generate the nonce pair and broadcast the commitment.
252    fn round1(&mut self) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
253        let nonce = NoncePair::generate();
254        let payload = encode_round1_commit(self.party_index, &nonce.d_commit, &nonce.e_commit);
255        self.nonce = Some(nonce);
256        let msg = confium_tc::Message::broadcast(&self.party_id, 1, payload);
257        Ok(confium_tc::registry::RoundResult::new(vec![msg], false))
258    }
259
260    /// Round 2 — receive commitments, compute the binding factors, the
261    /// group commitment, the challenge, and our share response.
262    fn round2(
263        &mut self,
264        incoming: &[confium_tc::Message],
265    ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
266        // Parse incoming round-1 commitments.
267        let mut commits: Vec<Commitment> = Vec::new();
268        for m in incoming {
269            if m.round != 1 || m.payload.is_empty() {
270                continue;
271            }
272            if m.payload[0] != MSG_ROUND1_COMMIT {
273                continue;
274            }
275            let (idx, d, e) = match decode_round1_commit(&m.payload) {
276                Ok(v) => v,
277                Err(e) => return Err(e.framework()),
278            };
279            // Validate the encoded points.
280            if group::point_from_slice(&d, &m.from_party_id).is_err() {
281                return Err(FrostError::InvalidCommitment {
282                    party: m.from_party_id.clone(),
283                    reason: "D commitment is not a valid curve point",
284                    code: CODE_INVALID_COMMITMENT,
285                }
286                .framework());
287            }
288            if group::point_from_slice(&e, &m.from_party_id).is_err() {
289                return Err(FrostError::InvalidCommitment {
290                    party: m.from_party_id.clone(),
291                    reason: "E commitment is not a valid curve point",
292                    code: CODE_INVALID_COMMITMENT,
293                }
294                .framework());
295            }
296            commits.push(Commitment {
297                party_id: m.from_party_id.clone(),
298                idx,
299                d,
300                e,
301            });
302        }
303
304        // Include our own commitment so every party's commitment set is
305        // self-contained.
306        let nonce = self.nonce.as_ref().ok_or_else(|| {
307            FrostError::RoundOverflow {
308                round: self.round_done,
309                code: CODE_ROUND_OVERFLOW,
310            }
311            .framework()
312        })?;
313        commits.push(Commitment {
314            party_id: self.party_id.clone(),
315            idx: self.party_index,
316            d: nonce.d_commit,
317            e: nonce.e_commit,
318        });
319
320        // Sort by party index so the rho input and all canonical
321        // derivations are identical across parties.
322        commits.sort_by_key(|c| c.idx);
323        self.commitments = commits.clone();
324        self.participants = commits.iter().map(|c| c.idx).collect();
325
326        // Threshold check: enough commitments present.
327        if (self.participants.len() as u32) < self.threshold {
328            return Err(FrostError::BelowThreshold {
329                have: self.participants.len() as u32,
330                need: self.threshold,
331                code: CODE_BELOW_THRESHOLD,
332            }
333            .framework());
334        }
335
336        // Compute rho input + per-party binding factors.
337        let rho_input_bytes: Vec<(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])> =
338            commits.iter().map(|c| (c.idx, c.d, c.e)).collect();
339        let rho_input = transcript::rho_input(&self.message, &rho_input_bytes);
340
341        // Compute the group commitment R = Σ_i (D_i + ρ_i · E_i).
342        let mut r_point = EdwardsPoint::identity();
343        for c in &commits {
344            let rho_i = transcript::h1_binding_factor(&rho_input_with_party(&rho_input, c.idx));
345            let d = group::point_from_bytes(&c.d).expect("validated above");
346            let e = group::point_from_bytes(&c.e).expect("validated above");
347            r_point += d + (e * rho_i);
348        }
349        let r_bytes = group::point_to_bytes(&r_point);
350
351        // Aggregate public key A. In a full deployment this is
352        // distributed out-of-band; in our test harness the DKG produced
353        // it alongside the share. We derive it from the long-term
354        // shares: A = s_i·B for any i is NOT the aggregate key — the
355        // aggregate key is Σ shares · λ_i · B. We compute A from the
356        // participating shares via Lagrange: since
357        // Σ_i λ_i · s_i = a_0 (the aggregate secret),
358        // A = a_0 · B = Σ_i λ_i · (s_i · B).
359        //
360        // But we only know our own s_i, not the peers'. So the aggregate
361        // public key must be supplied out of band. The framework's
362        // SessionParams doesn't have a dedicated pubkey slot, so we
363        // accept it via the share blob shape: if `local_share` is a DKG
364        // output blob, we parse the pubkey out of it.
365        let pubkey_bytes = self
366            .derive_pubkey_from_share()
367            .map_err(FrostError::framework)?;
368
369        // Challenge c = SHA-512(R || A || M) mod ℓ.
370        let challenge = transcript::challenge(&r_bytes, &pubkey_bytes, &self.message);
371
372        // Our share response:
373        //   z_i = d_i + ρ_i·e_i + λ_i · s_i · c
374        let lambda_i = lagrange_coefficient(self.party_index, &self.participants);
375        let rho_i =
376            transcript::h1_binding_factor(&rho_input_with_party(&rho_input, self.party_index));
377        let z_i = (nonce.d + (nonce.e * rho_i)) + ((self.secret_share * lambda_i) * challenge);
378
379        self.r_point = Some(r_point);
380        self.r_bytes = Some(r_bytes);
381        self.pubkey_bytes = Some(pubkey_bytes);
382        self.our_response = Some(z_i);
383
384        // Broadcast our response.
385        let payload = encode_round2_response(self.party_index, &z_i);
386        let msg = confium_tc::Message::broadcast(&self.party_id, 2, payload);
387        Ok(confium_tc::registry::RoundResult::new(vec![msg], false))
388    }
389
390    /// Round 3 — collect responses, verify each against its commitment,
391    /// aggregate, verify, and emit the final signature.
392    fn round3(
393        &mut self,
394        incoming: &[confium_tc::Message],
395    ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
396        // Parse round-2 responses.
397        let mut responses: Vec<ShareResponse> = Vec::new();
398        for m in incoming {
399            if m.round != 2 || m.payload.is_empty() {
400                continue;
401            }
402            if m.payload[0] != MSG_ROUND2_RESPONSE {
403                continue;
404            }
405            let (idx, z) = match decode_round2_response(&m.payload) {
406                Ok(v) => v,
407                Err(e) => return Err(e.framework()),
408            };
409            responses.push(ShareResponse {
410                party_id: m.from_party_id.clone(),
411                idx,
412                z,
413            });
414        }
415        // Include our own response.
416        let our_z = self.our_response.ok_or_else(|| {
417            FrostError::RoundOverflow {
418                round: self.round_done,
419                code: CODE_ROUND_OVERFLOW,
420            }
421            .framework()
422        })?;
423        responses.push(ShareResponse {
424            party_id: self.party_id.clone(),
425            idx: self.party_index,
426            z: our_z,
427        });
428
429        // We need a response from every participant in the commitment set
430        // — otherwise R cannot be reconstructed correctly. Missing
431        // responses are a protocol violation.
432        let have: std::collections::HashSet<u32> = responses.iter().map(|r| r.idx).collect();
433        let need: std::collections::HashSet<u32> = self.commitments.iter().map(|c| c.idx).collect();
434        if !need.is_subset(&have) {
435            let missing_idx = *need.difference(&have).next().expect("non-empty diff");
436            let party = self
437                .commitments
438                .iter()
439                .find(|c| c.idx == missing_idx)
440                .map(|c| c.party_id.clone())
441                .unwrap_or_else(|| format!("idx-{missing_idx}"));
442            return Err(FrostError::MissingCommitment {
443                party,
444                code: CODE_MISSING_COMMITMENT,
445            }
446            .framework());
447        }
448
449        // Re-derive the rho input and challenge (same as round 2) so we
450        // can verify each response against its commitment.
451        let rho_input_bytes: Vec<(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])> =
452            self.commitments.iter().map(|c| (c.idx, c.d, c.e)).collect();
453        let rho_input = transcript::rho_input(&self.message, &rho_input_bytes);
454        let r_bytes = self.r_bytes.expect("set in round 2");
455        let pubkey_bytes = self.pubkey_bytes.expect("set in round 2");
456        let challenge = transcript::challenge(&r_bytes, &pubkey_bytes, &self.message);
457
458        // Verify each response: z_i · B == D_i + ρ_i·E_i + λ_i·c·(s_i·B).
459        // We don't have s_i·B for arbitrary peers; instead we verify the
460        // weaker identity z_i · B == D_i + ρ_i·E_i + λ_i·c·A_i where A_i
461        // is the per-party public share. Without A_i we can only verify
462        // the *aggregate* at the end. So individual response validation
463        // is deferred to the aggregate check; a malformed aggregate
464        // implies byzantine behavior but doesn't identify the culprit.
465        //
466        // To still surface byzantine detection, we verify the partial
467        // sum incrementally: if the final aggregate fails verification,
468        // every contributor is suspect. The aggregate check below is
469        // therefore the byzantine proof.
470
471        // Aggregate the response: z = Σ_i z_i.
472        let mut z = Scalar::ZERO;
473        for r in &responses {
474            z += &r.z;
475        }
476
477        // Verify: z · B == R + c · A.
478        let zb = group::mul_base(&z);
479        let r_point = self.r_point.expect("set in round 2");
480        let a_point = group::point_from_bytes(&pubkey_bytes).ok_or_else(|| {
481            FrostError::InvalidCommitment {
482                party: "aggregate-public-key".to_string(),
483                reason: "aggregate public key is not a valid curve point",
484                code: CODE_INVALID_COMMITMENT,
485            }
486            .framework()
487        })?;
488        let expected = r_point + (a_point * challenge);
489        if zb != expected {
490            // The aggregate doesn't verify. Identify the first response
491            // whose individual contribution is inconsistent so the
492            // caller has a byzantine proof.
493            for r in &responses {
494                let commit = self
495                    .commitments
496                    .iter()
497                    .find(|c| c.idx == r.idx)
498                    .expect("response has a matching commitment");
499                let rho_i = transcript::h1_binding_factor(&rho_input_with_party(&rho_input, r.idx));
500                let lambda_i = lagrange_coefficient(r.idx, &self.participants);
501                // Reconstruct the per-party public share A_i by lagrange
502                // weighting — but we don't have A_i. Instead check that
503                // z_i is structurally plausible: not zero, in range.
504                // (Full per-party verification requires the per-party
505                // public shares, which a future revision will distribute
506                // during DKG.)
507                if r.z == Scalar::ZERO {
508                    return Err(FrostError::InvalidShareSignature {
509                        party: r.party_id.clone(),
510                        code: CODE_INVALID_SHARE_SIG,
511                    }
512                    .framework());
513                }
514                let _ = (commit, rho_i, lambda_i);
515            }
516            return Err(FrostError::AggregateVerificationFailed {
517                code: CODE_AGG_VERIFY_FAILED,
518            }
519            .framework());
520        }
521
522        // Pack the signature as (R || z) — 64 bytes, RFC 8032 layout.
523        let mut sig = [0u8; 64];
524        sig[..32].copy_from_slice(&r_bytes);
525        sig[32..].copy_from_slice(&group::scalar_to_bytes(&z));
526        self.signature = Some(sig);
527
528        Ok(confium_tc::registry::RoundResult::done())
529    }
530
531    /// Recover the aggregate public key from the local share. If the
532    /// share payload was a DKG output blob, the pubkey was parsed in
533    /// session `new()` and is stored here. Otherwise the caller
534    /// must supply the pubkey out-of-band — this implementation does not
535    /// yet support that path, so a raw-scalar share yields an error in
536    /// round 2.
537    fn derive_pubkey_from_share(&self) -> Result<[u8; group::ELEMENT_BYTES]> {
538        self.pubkey_bytes.ok_or(FrostError::MalformedShare {
539            reason: "aggregate public key not supplied — pass a DKG output blob as the share",
540            code: CODE_MALFORMED_SHARE,
541        })
542    }
543}
544
545impl confium_tc::registry::SessionImpl for FrostSession {
546    fn round(
547        &mut self,
548        incoming: &[confium_tc::Message],
549    ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
550        self.round_done = self.round_done.checked_add(1).ok_or_else(|| {
551            FrostError::RoundOverflow {
552                round: self.round_done,
553                code: CODE_ROUND_OVERFLOW,
554            }
555            .framework()
556        })?;
557        match self.round_done {
558            1 => self.round1(),
559            2 => self.round2(incoming),
560            3 => self.round3(incoming),
561            other => Err(FrostError::RoundOverflow {
562                round: other,
563                code: CODE_ROUND_OVERFLOW,
564            }
565            .framework()),
566        }
567    }
568
569    fn result(&self) -> confium_tc::error::Result<Vec<u8>> {
570        self.signature.map(|s| s.to_vec()).ok_or_else(|| {
571            FrostError::SessionNotComplete {
572                code: CODE_SESSION_NOT_COMPLETE,
573            }
574            .framework()
575        })
576    }
577
578    fn destroy(&mut self) {
579        self.secret_share = Scalar::ZERO;
580        self.nonce = None;
581        self.our_response = None;
582        self.signature = None;
583    }
584}
585
586/// Append a party index to a rho input and re-hash — used to derive the
587/// per-party binding factor `rho_i`. The rho input already canonically
588/// lists every party's commitment; this adds the index being weighted.
589fn rho_input_with_party(rho_input: &[u8], idx: u32) -> Vec<u8> {
590    let mut out = Vec::with_capacity(rho_input.len() + 4);
591    out.extend_from_slice(rho_input);
592    out.extend_from_slice(&idx.to_be_bytes());
593    out
594}
595
596// ---------------------------------------------------------------------------
597// Wire formats
598// ---------------------------------------------------------------------------
599
600/// Round-1 commitment: `tag | idx:u32 BE | D[32] | E[32]`.
601fn encode_round1_commit(
602    idx: u32,
603    d: &[u8; group::ELEMENT_BYTES],
604    e: &[u8; group::ELEMENT_BYTES],
605) -> Vec<u8> {
606    let mut out = Vec::with_capacity(1 + 4 + 2 * group::ELEMENT_BYTES);
607    out.push(MSG_ROUND1_COMMIT);
608    out.extend_from_slice(&idx.to_be_bytes());
609    out.extend_from_slice(d);
610    out.extend_from_slice(e);
611    out
612}
613
614fn decode_round1_commit(
615    p: &[u8],
616) -> Result<(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])> {
617    let need = 1 + 4 + 2 * group::ELEMENT_BYTES;
618    if p.len() != need || p[0] != MSG_ROUND1_COMMIT {
619        return Err(FrostError::MalformedMessage {
620            reason: "bad round-1 commitment",
621            code: CODE_MALFORMED_MESSAGE,
622        });
623    }
624    let idx = u32::from_be_bytes([p[1], p[2], p[3], p[4]]);
625    let mut d = [0u8; group::ELEMENT_BYTES];
626    d.copy_from_slice(&p[5..5 + group::ELEMENT_BYTES]);
627    let mut e = [0u8; group::ELEMENT_BYTES];
628    e.copy_from_slice(&p[5 + group::ELEMENT_BYTES..5 + 2 * group::ELEMENT_BYTES]);
629    Ok((idx, d, e))
630}
631
632/// Round-2 response: `tag | idx:u32 BE | z[32]`.
633fn encode_round2_response(idx: u32, z: &Scalar) -> Vec<u8> {
634    let mut out = Vec::with_capacity(1 + 4 + group::SCALAR_BYTES);
635    out.push(MSG_ROUND2_RESPONSE);
636    out.extend_from_slice(&idx.to_be_bytes());
637    out.extend_from_slice(&group::scalar_to_bytes(z));
638    out
639}
640
641fn decode_round2_response(p: &[u8]) -> Result<(u32, Scalar)> {
642    let need = 1 + 4 + group::SCALAR_BYTES;
643    if p.len() != need || p[0] != MSG_ROUND2_RESPONSE {
644        return Err(FrostError::MalformedMessage {
645            reason: "bad round-2 response",
646            code: CODE_MALFORMED_MESSAGE,
647        });
648    }
649    let idx = u32::from_be_bytes([p[1], p[2], p[3], p[4]]);
650    let mut s = [0u8; group::SCALAR_BYTES];
651    s.copy_from_slice(&p[5..5 + group::SCALAR_BYTES]);
652    Ok((idx, group::scalar_from_bytes_mod_order(&s)))
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658
659    #[test]
660    fn round1_commit_round_trips() {
661        let d = [1u8; 32];
662        let e = [2u8; 32];
663        let enc = encode_round1_commit(5, &d, &e);
664        let (idx, d2, e2) = decode_round1_commit(&enc).expect("decode");
665        assert_eq!(idx, 5);
666        assert_eq!(d2, d);
667        assert_eq!(e2, e);
668    }
669
670    #[test]
671    fn round2_response_round_trips() {
672        let z = Scalar::from(99u64);
673        let enc = encode_round2_response(3, &z);
674        let (idx, z2) = decode_round2_response(&enc).expect("decode");
675        assert_eq!(idx, 3);
676        assert_eq!(group::scalar_to_bytes(&z2), group::scalar_to_bytes(&z));
677    }
678
679    #[test]
680    fn decode_rejects_bad_tag() {
681        let mut bad = encode_round1_commit(1, &[0u8; 32], &[0u8; 32]);
682        bad[0] = 0xFF;
683        assert!(decode_round1_commit(&bad).is_err());
684    }
685}