Skip to main content

quorum_rs/
crypto.rs

1//! Transparent cryptographic signing for NSED agents.
2//!
3//! - [`AgentKeyPair`] — ergonomic wrapper around Ed25519 for agent identity
4//! - [`SigningHook`] — [`WorkerHook`] that wraps outbound payloads in [`AuditEnvelope`]
5//!
6//! Agent developers don't need to interact with crypto directly — the worker
7//! builder installs [`SigningHook`] by default with an auto-generated keypair.
8
9use crate::workers::WorkerHook;
10use anyhow::Result;
11use quorum_crypto_core::{AuditEnvelope, AuditSigner, signer::ed25519::Ed25519Signer};
12use std::sync::Arc;
13
14// ---------------------------------------------------------------------------
15// AgentKeyPair
16// ---------------------------------------------------------------------------
17
18/// Ergonomic wrapper around an Ed25519 signing key for agent identity.
19///
20/// Hides the [`AuditSigner`] trait — agent developers work with this struct
21/// directly. The inner signer is always Ed25519 (matching NATS NKey scheme).
22#[derive(Debug, Clone)]
23pub struct AgentKeyPair {
24    signer: Arc<Ed25519Signer>,
25}
26
27impl AgentKeyPair {
28    /// Generate a new random keypair.
29    pub fn generate() -> Self {
30        Self {
31            signer: Arc::new(Ed25519Signer::generate()),
32        }
33    }
34
35    /// Create from a 32-byte seed (deterministic — same seed → same key).
36    pub fn from_seed(seed: &[u8; 32]) -> Self {
37        Self {
38            signer: Arc::new(Ed25519Signer::from_seed(seed)),
39        }
40    }
41
42    /// Create from the `NSED_AGENT_SEED` environment variable (hex-encoded 32 bytes).
43    ///
44    /// Returns `None` if the env var is missing or invalid.
45    pub fn from_env(var_name: &str) -> Option<Self> {
46        let hex_seed = std::env::var(var_name).ok()?;
47        let bytes = hex::decode(hex_seed.trim()).ok()?;
48        if bytes.len() != 32 {
49            return None;
50        }
51        let mut seed = [0u8; 32];
52        seed.copy_from_slice(&bytes);
53        Some(Self::from_seed(&seed))
54    }
55
56    /// Get the public key as hex string for display/logging.
57    pub fn public_key_hex(&self) -> String {
58        hex::encode(self.signer.public_key_bytes())
59    }
60
61    /// Access the inner signer (for advanced use / interop with nsed-crypto).
62    pub fn signer(&self) -> &Arc<Ed25519Signer> {
63        &self.signer
64    }
65
66    /// Get an `Arc<dyn AuditSigner>` for use with envelope signing.
67    pub fn as_audit_signer(&self) -> Arc<dyn AuditSigner> {
68        self.signer.clone()
69    }
70}
71
72// ---------------------------------------------------------------------------
73// SigningHook
74// ---------------------------------------------------------------------------
75
76/// [`WorkerHook`] that wraps outbound NATS payloads in signed [`AuditEnvelope`]s.
77///
78/// Installed automatically by the worker builder. Agent developers don't
79/// interact with this directly.
80///
81/// The hook:
82/// 1. Deserializes the raw payload as `serde_json::Value`
83/// 2. Extracts `agent_id` from the NATS subject
84/// 3. Wraps in `AuditEnvelope::signed()` with the agent's keypair
85/// 4. Replaces the payload bytes with the serialized envelope
86///
87/// If signing fails (shouldn't happen with Ed25519), the original payload
88/// is passed through unchanged with a warning log.
89#[derive(Debug)]
90pub struct SigningHook {
91    keypair: AgentKeyPair,
92    agent_id: String,
93}
94
95impl SigningHook {
96    /// Create a new signing hook for the given agent.
97    pub fn new(keypair: AgentKeyPair, agent_id: String) -> Self {
98        Self { keypair, agent_id }
99    }
100
101    /// Extract the audit-relevant subject type from a NATS subject.
102    ///
103    /// Only deliberation content subjects are signed:
104    /// - `nsed.{session}.result.{round}.{agent_id}.propose` → `Some("proposal")`
105    /// - `nsed.{session}.result.{round}.{agent_id}.evaluate` → `Some("evaluation")`
106    /// - Everything else (heartbeats, manifest ACKs, control messages) → `None`
107    ///
108    /// Returns `None` for subjects that should pass through unsigned.
109    fn audit_subject_type(subject: &str) -> Option<&'static str> {
110        let last = subject.rsplit('.').next().unwrap_or("");
111        match last {
112            "propose" => Some("proposal"),
113            "evaluate" => Some("evaluation"),
114            _ => None,
115        }
116    }
117}
118
119#[async_trait::async_trait]
120impl WorkerHook for SigningHook {
121    async fn before_publish(&self, subject: &str, payload: &mut Vec<u8>) -> Result<()> {
122        // Only sign audit-relevant subjects (proposals, evaluations).
123        // Control-plane messages (manifest ACKs, heartbeats) pass through unsigned.
124        let Some(subject_type) = Self::audit_subject_type(subject) else {
125            return Ok(());
126        };
127
128        // Parse payload as JSON Value for envelope wrapping
129        let value: serde_json::Value = match serde_json::from_slice(payload) {
130            Ok(v) => v,
131            Err(e) => {
132                tracing::warn!(
133                    agent_id = %self.agent_id,
134                    error = %e,
135                    "SigningHook: payload is not JSON, passing through unsigned"
136                );
137                return Ok(());
138            }
139        };
140
141        // Sign into an AuditEnvelope
142        let signer = self.keypair.as_audit_signer();
143        match AuditEnvelope::signed(value, subject_type, &self.agent_id, &*signer).await {
144            Ok(envelope) => {
145                // Replace payload with the serialized envelope
146                match serde_json::to_vec(&envelope) {
147                    Ok(signed_bytes) => {
148                        *payload = signed_bytes;
149                    }
150                    Err(e) => {
151                        tracing::warn!(
152                            agent_id = %self.agent_id,
153                            error = %e,
154                            "SigningHook: failed to serialize envelope, passing unsigned"
155                        );
156                    }
157                }
158            }
159            Err(e) => {
160                tracing::warn!(
161                    agent_id = %self.agent_id,
162                    error = %e,
163                    "SigningHook: signing failed, passing unsigned"
164                );
165            }
166        }
167
168        Ok(())
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Tests
174// ---------------------------------------------------------------------------
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    // ---- AgentKeyPair ----
181
182    #[test]
183    fn keypair_generate_produces_unique_keys() {
184        let a = AgentKeyPair::generate();
185        let b = AgentKeyPair::generate();
186        assert_ne!(a.public_key_hex(), b.public_key_hex());
187    }
188
189    #[test]
190    fn keypair_from_seed_is_deterministic() {
191        let seed = [42u8; 32];
192        let a = AgentKeyPair::from_seed(&seed);
193        let b = AgentKeyPair::from_seed(&seed);
194        assert_eq!(a.public_key_hex(), b.public_key_hex());
195    }
196
197    #[test]
198    fn keypair_from_seed_different_seeds_differ() {
199        let a = AgentKeyPair::from_seed(&[1u8; 32]);
200        let b = AgentKeyPair::from_seed(&[2u8; 32]);
201        assert_ne!(a.public_key_hex(), b.public_key_hex());
202    }
203
204    #[test]
205    fn keypair_public_key_hex_is_64_chars() {
206        let kp = AgentKeyPair::generate();
207        assert_eq!(kp.public_key_hex().len(), 64); // 32 bytes = 64 hex chars
208    }
209
210    #[test]
211    fn keypair_from_env_missing_returns_none() {
212        assert!(AgentKeyPair::from_env("NSED_TEST_NONEXISTENT_SEED_VAR").is_none());
213    }
214
215    #[test]
216    #[serial_test::serial]
217    fn keypair_from_env_invalid_hex_returns_none() {
218        // SAFETY: test-only env var manipulation, tests run single-threaded
219        unsafe { std::env::set_var("NSED_TEST_BAD_SEED", "not-hex") };
220        assert!(AgentKeyPair::from_env("NSED_TEST_BAD_SEED").is_none());
221        unsafe { std::env::remove_var("NSED_TEST_BAD_SEED") };
222    }
223
224    #[test]
225    #[serial_test::serial]
226    fn keypair_from_env_wrong_length_returns_none() {
227        unsafe { std::env::set_var("NSED_TEST_SHORT_SEED", "abcd1234") };
228        assert!(AgentKeyPair::from_env("NSED_TEST_SHORT_SEED").is_none());
229        unsafe { std::env::remove_var("NSED_TEST_SHORT_SEED") };
230    }
231
232    #[test]
233    #[serial_test::serial]
234    fn keypair_from_env_valid_works() {
235        let seed = [99u8; 32];
236        let hex_seed = hex::encode(seed);
237        unsafe { std::env::set_var("NSED_TEST_VALID_SEED", &hex_seed) };
238        let kp = AgentKeyPair::from_env("NSED_TEST_VALID_SEED").unwrap();
239        let expected = AgentKeyPair::from_seed(&seed);
240        assert_eq!(kp.public_key_hex(), expected.public_key_hex());
241        unsafe { std::env::remove_var("NSED_TEST_VALID_SEED") };
242    }
243
244    #[test]
245    fn keypair_as_audit_signer_returns_ed25519() {
246        let kp = AgentKeyPair::generate();
247        let signer = kp.as_audit_signer();
248        assert_eq!(signer.algorithm(), "ed25519");
249    }
250
251    // ---- SigningHook ----
252
253    #[test]
254    fn audit_subject_type_extracts_proposal() {
255        assert_eq!(
256            SigningHook::audit_subject_type("nsed.abc.result.1.agent-1.propose"),
257            Some("proposal")
258        );
259    }
260
261    #[test]
262    fn audit_subject_type_extracts_evaluation() {
263        assert_eq!(
264            SigningHook::audit_subject_type("nsed.abc.result.1.agent-1.evaluate"),
265            Some("evaluation")
266        );
267    }
268
269    #[test]
270    fn audit_subject_type_returns_none_for_non_audit() {
271        // Control-plane subjects should not be signed
272        assert_eq!(
273            SigningHook::audit_subject_type("nsed.abc.result.something"),
274            None
275        );
276        assert_eq!(
277            SigningHook::audit_subject_type("sphera.jobs.ack.job1.agent1"),
278            None
279        );
280    }
281
282    #[test]
283    fn audit_subject_type_handles_empty() {
284        assert_eq!(SigningHook::audit_subject_type(""), None);
285    }
286
287    #[tokio::test]
288    async fn signing_hook_skips_non_audit_subjects() {
289        let kp = AgentKeyPair::generate();
290        let hook = SigningHook::new(kp, "agent".to_string());
291
292        let original = serde_json::json!({"manifest": true});
293        let mut payload = serde_json::to_vec(&original).unwrap();
294        let original_bytes = payload.clone();
295
296        // Manifest ACK subject — should pass through unsigned
297        hook.before_publish("sphera.jobs.ack.job1.agent1", &mut payload)
298            .await
299            .unwrap();
300
301        assert_eq!(
302            payload, original_bytes,
303            "Non-audit subject should not be wrapped"
304        );
305    }
306
307    #[tokio::test]
308    async fn signing_hook_wraps_json_in_envelope() {
309        let kp = AgentKeyPair::from_seed(&[1u8; 32]);
310        let hook = SigningHook::new(kp.clone(), "test-agent".to_string());
311
312        let original = serde_json::json!({"content": "hello", "thought_process": "thinking"});
313        let mut payload = serde_json::to_vec(&original).unwrap();
314
315        hook.before_publish("nsed.session.result.1.test-agent.propose", &mut payload)
316            .await
317            .unwrap();
318
319        // Payload should now be an AuditEnvelope
320        let envelope: AuditEnvelope<serde_json::Value> = serde_json::from_slice(&payload).unwrap();
321
322        assert_eq!(envelope.agent_id(), "test-agent");
323        assert_eq!(envelope.subject(), "proposal");
324        assert_eq!(envelope.payload()["content"], "hello");
325        assert_eq!(envelope.signature_count(), 1);
326        assert!(envelope.has_role(&quorum_crypto_core::envelope::SignerRole::Author));
327    }
328
329    #[tokio::test]
330    async fn signing_hook_envelope_verifies() {
331        let kp = AgentKeyPair::from_seed(&[2u8; 32]);
332        let hook = SigningHook::new(kp.clone(), "verify-agent".to_string());
333
334        let mut payload = serde_json::to_vec(&serde_json::json!({"score": 8.5})).unwrap();
335
336        hook.before_publish("nsed.session.result.1.verify-agent.evaluate", &mut payload)
337            .await
338            .unwrap();
339
340        let mut envelope: AuditEnvelope<serde_json::Value> =
341            serde_json::from_slice(&payload).unwrap();
342
343        let registry = quorum_crypto_core::VerifierRegistry::with_defaults();
344        assert!(envelope.verify_chain(&registry).unwrap());
345    }
346
347    #[tokio::test]
348    async fn signing_hook_passes_through_non_json() {
349        let kp = AgentKeyPair::generate();
350        let hook = SigningHook::new(kp, "agent".to_string());
351
352        let mut payload = b"not json".to_vec();
353        let original = payload.clone();
354
355        hook.before_publish("nsed.session.result.1.agent.propose", &mut payload)
356            .await
357            .unwrap();
358
359        // Non-JSON payload should pass through unchanged
360        assert_eq!(payload, original);
361    }
362
363    #[tokio::test]
364    async fn signing_hook_deterministic_with_same_seed() {
365        let seed = [3u8; 32];
366        let hook1 = SigningHook::new(AgentKeyPair::from_seed(&seed), "agent".to_string());
367        let hook2 = SigningHook::new(AgentKeyPair::from_seed(&seed), "agent".to_string());
368
369        let json = serde_json::json!({"test": true});
370        let mut p1 = serde_json::to_vec(&json).unwrap();
371        let mut p2 = serde_json::to_vec(&json).unwrap();
372
373        hook1
374            .before_publish("nsed.s.result.1.agent.propose", &mut p1)
375            .await
376            .unwrap();
377        hook2
378            .before_publish("nsed.s.result.1.agent.propose", &mut p2)
379            .await
380            .unwrap();
381
382        // Both should produce valid envelopes with the same public key
383        let env1: AuditEnvelope<serde_json::Value> = serde_json::from_slice(&p1).unwrap();
384        let env2: AuditEnvelope<serde_json::Value> = serde_json::from_slice(&p2).unwrap();
385
386        assert_eq!(
387            env1.signatures()[0].public_key,
388            env2.signatures()[0].public_key
389        );
390    }
391
392    #[tokio::test]
393    async fn signing_hook_different_subjects_produce_different_envelopes() {
394        let kp = AgentKeyPair::from_seed(&[4u8; 32]);
395        let hook = SigningHook::new(kp, "agent".to_string());
396
397        let json = serde_json::json!({"data": 1});
398        let mut p1 = serde_json::to_vec(&json).unwrap();
399        let mut p2 = serde_json::to_vec(&json).unwrap();
400
401        hook.before_publish("nsed.s.result.1.agent.propose", &mut p1)
402            .await
403            .unwrap();
404        hook.before_publish("nsed.s.result.1.agent.evaluate", &mut p2)
405            .await
406            .unwrap();
407
408        let env1: AuditEnvelope<serde_json::Value> = serde_json::from_slice(&p1).unwrap();
409        let env2: AuditEnvelope<serde_json::Value> = serde_json::from_slice(&p2).unwrap();
410
411        assert_eq!(env1.subject(), "proposal");
412        assert_eq!(env2.subject(), "evaluation");
413        // Different subjects → different signatures
414        assert_ne!(
415            env1.signatures()[0].signature,
416            env2.signatures()[0].signature
417        );
418    }
419}