1use crate::workers::WorkerHook;
10use anyhow::Result;
11use quorum_crypto_core::{AuditEnvelope, AuditSigner, signer::ed25519::Ed25519Signer};
12use std::sync::Arc;
13
14#[derive(Debug, Clone)]
23pub struct AgentKeyPair {
24 signer: Arc<Ed25519Signer>,
25}
26
27impl AgentKeyPair {
28 pub fn generate() -> Self {
30 Self {
31 signer: Arc::new(Ed25519Signer::generate()),
32 }
33 }
34
35 pub fn from_seed(seed: &[u8; 32]) -> Self {
37 Self {
38 signer: Arc::new(Ed25519Signer::from_seed(seed)),
39 }
40 }
41
42 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 pub fn public_key_hex(&self) -> String {
58 hex::encode(self.signer.public_key_bytes())
59 }
60
61 pub fn signer(&self) -> &Arc<Ed25519Signer> {
63 &self.signer
64 }
65
66 pub fn as_audit_signer(&self) -> Arc<dyn AuditSigner> {
68 self.signer.clone()
69 }
70}
71
72#[derive(Debug)]
90pub struct SigningHook {
91 keypair: AgentKeyPair,
92 agent_id: String,
93}
94
95impl SigningHook {
96 pub fn new(keypair: AgentKeyPair, agent_id: String) -> Self {
98 Self { keypair, agent_id }
99 }
100
101 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 let Some(subject_type) = Self::audit_subject_type(subject) else {
125 return Ok(());
126 };
127
128 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 let signer = self.keypair.as_audit_signer();
143 match AuditEnvelope::signed(value, subject_type, &self.agent_id, &*signer).await {
144 Ok(envelope) => {
145 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#[cfg(test)]
177mod tests {
178 use super::*;
179
180 #[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); }
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 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 #[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 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 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 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(®istry).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 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 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 assert_ne!(
415 env1.signatures()[0].signature,
416 env2.signatures()[0].signature
417 );
418 }
419}