Skip to main content

yip_ca/
signer_daemon.rs

1use std::collections::BTreeMap;
2use std::sync::Mutex;
3
4use frost_ed25519 as frost;
5use rand::rngs::OsRng;
6use rand::RngCore;
7
8use crate::rpc::{NonceSlotId, QuorumRpcMessage, SignCertRequestPayload, SignCertResponsePayload};
9
10/// Policy rules enforced independently by each signer before computing partial signatures.
11#[derive(Clone, Debug)]
12pub struct SignerPolicy {
13    pub max_ttl_secs: u64,
14    pub admin_provisioning_pubkey: Option<ed25519_dalek::VerifyingKey>,
15    pub ipv4_network_mask: Option<(u32, u32)>,
16}
17
18impl Default for SignerPolicy {
19    fn default() -> Self {
20        Self {
21            max_ttl_secs: 7 * 86_400, // 7 days max TTL
22            admin_provisioning_pubkey: None,
23            ipv4_network_mask: None,
24        }
25    }
26}
27
28/// Ephemeral, in-memory vault for single-use FROST signing nonces.
29/// Bound to a CSPRNG-generated `boot_epoch_id` to guarantee rollback immunity.
30pub struct EphemeralNonceVault {
31    boot_epoch_id: u128,
32    next_gen_idx: u64,
33    active_nonces: BTreeMap<NonceSlotId, frost::round1::SigningNonces>,
34}
35
36impl EphemeralNonceVault {
37    /// Creates a fresh vault instance with a unique 128-bit boot epoch ID.
38    pub fn new() -> Self {
39        let mut bytes = [0u8; 16];
40        OsRng.fill_bytes(&mut bytes);
41        Self {
42            boot_epoch_id: u128::from_le_bytes(bytes),
43            next_gen_idx: 1,
44            active_nonces: BTreeMap::new(),
45        }
46    }
47
48    /// The 128-bit boot epoch identifier for this vault instance.
49    pub fn boot_epoch_id(&self) -> u128 {
50        self.boot_epoch_id
51    }
52
53    /// Generates `count` fresh nonces and returns public commitments for the Coordinator.
54    pub fn preprocess_batch(
55        &mut self,
56        signing_share: &frost::keys::SigningShare,
57        count: usize,
58    ) -> Vec<(NonceSlotId, frost::round1::SigningCommitments)> {
59        let mut out = Vec::with_capacity(count);
60        let mut rng = OsRng;
61        for _ in 0..count {
62            let seq = self.next_gen_idx;
63            self.next_gen_idx = self
64                .next_gen_idx
65                .checked_add(1)
66                .expect("nonce counter overflow");
67            let (nonces, commitments) = frost::round1::commit(signing_share, &mut rng);
68            let slot = NonceSlotId {
69                boot_epoch_id: self.boot_epoch_id,
70                sequence_idx: seq,
71            };
72            self.active_nonces.insert(slot, nonces);
73            out.push((slot, commitments));
74        }
75        out
76    }
77
78    /// Atomically removes and returns the secret SigningNonces for `slot`.
79    /// Enforces the single-use invariant: once taken, subsequent calls return None.
80    pub fn take_once(&mut self, slot: &NonceSlotId) -> Option<frost::round1::SigningNonces> {
81        if slot.boot_epoch_id != self.boot_epoch_id {
82            return None;
83        }
84        self.active_nonces.remove(slot)
85    }
86
87    /// Count of active unconsumed nonces.
88    pub fn len(&self) -> usize {
89        self.active_nonces.len()
90    }
91
92    /// Checks if the vault is empty.
93    pub fn is_empty(&self) -> bool {
94        self.active_nonces.is_empty()
95    }
96}
97
98impl Default for EphemeralNonceVault {
99    fn default() -> Self {
100        Self::new()
101    }
102}
103
104/// Signer daemon state holding the KeyPackage share and policy.
105pub struct SignerState {
106    pub key_package: frost::keys::KeyPackage,
107    pub pubkey_package: frost::keys::PublicKeyPackage,
108    pub policy: SignerPolicy,
109    pub vault: Mutex<EphemeralNonceVault>,
110}
111
112impl SignerState {
113    pub fn new(
114        key_package: frost::keys::KeyPackage,
115        pubkey_package: frost::keys::PublicKeyPackage,
116        policy: SignerPolicy,
117    ) -> Self {
118        Self {
119            key_package,
120            pubkey_package,
121            policy,
122            vault: Mutex::new(EphemeralNonceVault::new()),
123        }
124    }
125
126    /// Handles a sign request from the Coordinator: validates payload and policy,
127    /// atomically consumes the nonce, generates a replenishment commitment, and emits the share.
128    pub fn handle_sign_request(&self, req: &SignCertRequestPayload) -> QuorumRpcMessage {
129        let signer_id = *self.key_package.identifier();
130        let session_id = req.session_id;
131
132        // 1. Verify SigningPackage payload matches canonical CertTbs
133        let expected_msg = req.cert_tbs.to_canonical_bytes();
134        if req.signing_package.message() != expected_msg.as_slice() {
135            return QuorumRpcMessage::SignCertReject {
136                session_id,
137                signer_id,
138                reason: "SigningPackage payload mismatch with CertTbs".into(),
139            };
140        }
141
142        // 2. Policy bounds checks
143        if req.cert_tbs.not_after <= req.cert_tbs.not_before
144            || (req.cert_tbs.not_after - req.cert_tbs.not_before) > self.policy.max_ttl_secs
145        {
146            return QuorumRpcMessage::SignCertReject {
147                session_id,
148                signer_id,
149                reason: "Certificate TTL exceeds signer policy maximum".into(),
150            };
151        }
152
153        // 3. Atomically consume the one-time nonce under mutex lock and generate 1 replenishment
154        let (nonces, replenish) = {
155            let mut vault = match self.vault.lock() {
156                Ok(guard) => guard,
157                Err(poisoned) => poisoned.into_inner(),
158            };
159            let Some(n) = vault.take_once(&req.slot_id) else {
160                return QuorumRpcMessage::SignCertReject {
161                    session_id,
162                    signer_id,
163                    reason: "NonceSlotId not found, already consumed, or stale boot epoch".into(),
164                };
165            };
166            let rep = vault
167                .preprocess_batch(self.key_package.signing_share(), 1)
168                .pop();
169            (n, rep)
170        };
171
172        // 4. Compute FROST Round 2 partial signature; nonces auto-zeroized on drop
173        match frost::round2::sign(&req.signing_package, &nonces, &self.key_package) {
174            Ok(signature_share) => {
175                QuorumRpcMessage::SignCertResponse(Box::new(SignCertResponsePayload {
176                    session_id,
177                    signer_id,
178                    signature_share,
179                    replenish_commitment: replenish,
180                }))
181            }
182            Err(e) => QuorumRpcMessage::SignCertReject {
183                session_id,
184                signer_id,
185                reason: format!("FROST round2 sign error: {e}"),
186            },
187        }
188    }
189}