Skip to main content

confium_signerd/
daemon.rs

1//! Signer daemon — connects to coordinator and responds to signing requests.
2
3use crate::config::DaemonConfig;
4use confium_coordinator::coordinator::client::SignerClient;
5use confium_coordinator::coordinator::net::{ProtocolMessage, recv_message, send_message};
6use std::io;
7use std::path::Path;
8
9/// The running daemon. Manages the coordinator connection and
10/// responds to signing requests.
11pub struct SignerDaemon {
12    config: DaemonConfig,
13}
14
15impl SignerDaemon {
16    /// Create a new daemon from configuration.
17    pub fn new(config: DaemonConfig) -> Self {
18        Self { config }
19    }
20
21    /// Connect to the coordinator, register, and enter the main loop.
22    /// Returns when the connection drops and retries are exhausted.
23    pub fn run(&self) -> RunResult {
24        let mut attempts = 0u32;
25        loop {
26            tracing::info!(
27                addr = self
28                    .config
29                    .coordinator_url
30                    .as_deref()
31                    .unwrap_or(&self.config.coordinator_addr),
32                attempt = attempts,
33                "connecting to coordinator"
34            );
35            match self.connect_and_serve() {
36                Ok(()) => return RunResult::Disconnected,
37                Err(e) => {
38                    tracing::warn!(error = %e, "connection lost");
39                    attempts += 1;
40                    let max = self.config.max_reconnect_attempts;
41                    if max > 0 && attempts >= max {
42                        tracing::error!(attempts, "max reconnect attempts reached, giving up");
43                        return RunResult::MaxRetriesExhausted;
44                    }
45                    let backoff = self.config.reconnect_backoff_secs;
46                    tracing::info!(backoff_secs = backoff, "sleeping before reconnect");
47                    std::thread::sleep(std::time::Duration::from_secs(backoff));
48                }
49            }
50        }
51    }
52
53    fn connect_and_serve(&self) -> io::Result<()> {
54        let mut client = match &self.config.coordinator_url {
55            Some(url) => SignerClient::connect_url(url)?,
56            None => SignerClient::connect(&self.config.coordinator_addr)?,
57        };
58        client.register(&self.config.signer_id, &self.config.quorum_id)?;
59        tracing::info!(signer_id = %self.config.signer_id, "registered with coordinator");
60
61        let share_bytes = self.load_share()?;
62
63        loop {
64            let msg = client.recv()?;
65            match msg {
66                ProtocolMessage::SessionPending {
67                    session_id,
68                    message,
69                    threshold: _,
70                } => {
71                    tracing::info!(session = %session_id, "received signing request");
72                    self.handle_signing_request(
73                        client.stream_mut(),
74                        &session_id,
75                        &message,
76                        &share_bytes,
77                    )?;
78                }
79                ProtocolMessage::HealthCheck => {
80                    send_message(
81                        client.stream_mut(),
82                        &ProtocolMessage::HealthStatus {
83                            alive: true,
84                            ready: true,
85                            session_count: 0,
86                            uptime_seconds: 0,
87                        },
88                    )?;
89                }
90                _ => {
91                    tracing::debug!(msg = ?msg, "ignoring unexpected message");
92                }
93            }
94        }
95    }
96
97    fn handle_signing_request(
98        &self,
99        stream: &mut Box<dyn confium_coordinator::coordinator::net_server::SessionIo>,
100        session_id: &str,
101        _message: &[u8],
102        share_bytes: &[u8],
103    ) -> io::Result<()> {
104        let commitment = self.derive_commitment(share_bytes);
105        send_message(
106            stream,
107            &ProtocolMessage::Commitment {
108                session_id: session_id.into(),
109                signer_id: self.config.signer_id.clone(),
110                bytes: commitment,
111                signature: vec![0u8; 64],
112            },
113        )?;
114        let _ = recv_message(stream)?;
115
116        send_message(
117            stream,
118            &ProtocolMessage::Share {
119                session_id: session_id.into(),
120                signer_id: self.config.signer_id.clone(),
121                bytes: share_bytes.to_vec(),
122                signature: vec![0u8; 64],
123            },
124        )?;
125
126        match recv_message(stream) {
127            Ok(ProtocolMessage::Signature { bytes, .. }) => {
128                tracing::info!(
129                    session = session_id,
130                    sig_len = bytes.len(),
131                    "signature aggregated"
132                );
133            }
134            Ok(ProtocolMessage::Ack { .. }) => {
135                tracing::info!(
136                    session = session_id,
137                    "share submitted, waiting for more signers"
138                );
139            }
140            Ok(ProtocolMessage::Error { message }) => {
141                tracing::error!(session = session_id, error = %message, "signing failed");
142            }
143            _ => {}
144        }
145        Ok(())
146    }
147
148    fn derive_commitment(&self, share_bytes: &[u8]) -> Vec<u8> {
149        use sha2::{Digest, Sha256};
150        let mut hasher = Sha256::new();
151        hasher.update(share_bytes);
152        hasher.update(self.config.signer_id.as_bytes());
153        hasher.update([0u8; 8]);
154        hasher.finalize().to_vec()
155    }
156
157    fn load_share(&self) -> io::Result<Vec<u8>> {
158        let path = Path::new(&self.config.share_file);
159        std::fs::read(path).map_err(|e| {
160            io::Error::new(
161                io::ErrorKind::NotFound,
162                format!("share file {}: {e}", path.display()),
163            )
164        })
165    }
166}
167
168/// Why the daemon stopped.
169#[derive(Debug)]
170pub enum RunResult {
171    /// Connection ended cleanly.
172    Disconnected,
173    /// All reconnect attempts failed.
174    MaxRetriesExhausted,
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use std::io::Write;
181
182    fn make_config() -> DaemonConfig {
183        DaemonConfig {
184            coordinator_url: None,
185            coordinator_addr: "127.0.0.1:0".into(),
186            signer_id: "test-signer".into(),
187            quorum_id: "test-quorum".into(),
188            share_file: "/dev/null".into(),
189            scheme: "CMP20".into(),
190            reconnect_backoff_secs: 1,
191            max_reconnect_attempts: 1,
192        }
193    }
194
195    #[test]
196    fn daemon_constructs_from_config() {
197        let config = make_config();
198        let daemon = SignerDaemon::new(config);
199        assert_eq!(daemon.config.signer_id, "test-signer");
200    }
201
202    #[test]
203    fn derive_commitment_is_deterministic() {
204        let config = make_config();
205        let daemon = SignerDaemon::new(config);
206        let share = vec![0xAA; 32];
207        let c1 = daemon.derive_commitment(&share);
208        let c2 = daemon.derive_commitment(&share);
209        assert_eq!(c1, c2);
210        assert_eq!(c1.len(), 32);
211    }
212
213    #[test]
214    fn derive_commitment_differs_for_different_shares() {
215        let config = make_config();
216        let daemon = SignerDaemon::new(config);
217        let c1 = daemon.derive_commitment(&[0xAA; 32]);
218        let c2 = daemon.derive_commitment(&[0xBB; 32]);
219        assert_ne!(c1, c2);
220    }
221
222    #[test]
223    fn load_share_reads_file() {
224        let mut tmp = tempfile::NamedTempFile::new().unwrap();
225        tmp.write_all(&[0x42; 64]).unwrap();
226        let mut config = make_config();
227        config.share_file = tmp.path().to_string_lossy().to_string();
228        let daemon = SignerDaemon::new(config);
229        let share = daemon.load_share().unwrap();
230        assert_eq!(share, vec![0x42; 64]);
231    }
232
233    #[test]
234    fn load_share_missing_file_errors() {
235        let mut config = make_config();
236        config.share_file = "/nonexistent/path/share.json".into();
237        let daemon = SignerDaemon::new(config);
238        assert!(daemon.load_share().is_err());
239    }
240}