1use crate::models::field_names;
94use anyhow::{Context, Result};
95use base64::Engine as _;
96use base64::engine::general_purpose::URL_SAFE_NO_PAD;
97use ed25519_dalek::{Signature, Signer, Verifier, VerifyingKey};
98use serde_json::{Value, json};
99
100use crate::identity::keypair::AgentKeypair;
101use crate::storage::migrations::current_schema_version;
102
103const PUBLIC_KEY_FIELD: &str = "public_key";
106
107#[derive(Debug, Clone)]
114pub struct DaemonIdentityToSign<'a> {
115 pub schema_version: &'a str,
123 pub daemon_id: &'a str,
126 pub public_key: &'a str,
129 pub signed_at: &'a str,
133}
134
135pub fn canonical_bytes_for_identity(identity: &DaemonIdentityToSign<'_>) -> Result<Vec<u8>> {
151 let canonical = json!({
152 (field_names::SCHEMA_VERSION): identity.schema_version,
153 "daemon_id": identity.daemon_id,
154 (PUBLIC_KEY_FIELD): identity.public_key,
155 "signed_at": identity.signed_at,
156 });
157 serde_json::to_vec(&canonical)
158 .context("server_identity::canonical_bytes_for_identity: serialize")
159}
160
161pub fn build_signed_identity(
182 keypair: Option<&AgentKeypair>,
183 now_rfc3339: &str,
184) -> Result<Option<Value>> {
185 let Some(kp) = keypair else {
186 return Ok(None);
187 };
188 let Some(signing_key) = kp.private.as_ref() else {
189 return Ok(None);
190 };
191
192 let schema_version = format!("v{}", current_schema_version());
193 let public_key = kp.public_base64();
194 let identity = DaemonIdentityToSign {
195 schema_version: &schema_version,
196 daemon_id: &kp.agent_id,
197 public_key: &public_key,
198 signed_at: now_rfc3339,
199 };
200 let canonical = canonical_bytes_for_identity(&identity)?;
201 let signature: Signature = signing_key.sign(&canonical);
202 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
203
204 Ok(Some(json!({
205 (field_names::SCHEMA_VERSION): schema_version,
206 "daemon_id": kp.agent_id,
207 (PUBLIC_KEY_FIELD): public_key,
208 "signed_at": now_rfc3339,
209 "signature": sig_b64,
210 })))
211}
212
213pub fn verify_signed_identity(block: &Value) -> Result<(), ed25519_dalek::SignatureError> {
236 let make_err = ed25519_dalek::SignatureError::new;
237
238 let obj = block.as_object().ok_or_else(make_err)?;
239 let schema_version = obj
240 .get(field_names::SCHEMA_VERSION)
241 .and_then(Value::as_str)
242 .ok_or_else(make_err)?;
243 let daemon_id = obj
244 .get("daemon_id")
245 .and_then(Value::as_str)
246 .ok_or_else(make_err)?;
247 let public_key_b64 = obj
248 .get(PUBLIC_KEY_FIELD)
249 .and_then(Value::as_str)
250 .ok_or_else(make_err)?;
251 let signed_at = obj
252 .get("signed_at")
253 .and_then(Value::as_str)
254 .ok_or_else(make_err)?;
255 let signature_b64 = obj
256 .get("signature")
257 .and_then(Value::as_str)
258 .ok_or_else(make_err)?;
259
260 let public_key_bytes = URL_SAFE_NO_PAD
261 .decode(public_key_b64)
262 .map_err(|_| make_err())?;
263 let signature_bytes = URL_SAFE_NO_PAD
264 .decode(signature_b64)
265 .map_err(|_| make_err())?;
266
267 if public_key_bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
268 return Err(make_err());
269 }
270 if signature_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
271 return Err(make_err());
272 }
273 let mut pk_arr = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
274 pk_arr.copy_from_slice(&public_key_bytes);
275 let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
276 sig_arr.copy_from_slice(&signature_bytes);
277
278 let verifying_key = VerifyingKey::from_bytes(&pk_arr).map_err(|_| make_err())?;
279 let signature = Signature::from_bytes(&sig_arr);
280
281 let identity = DaemonIdentityToSign {
282 schema_version,
283 daemon_id,
284 public_key: public_key_b64,
285 signed_at,
286 };
287 let canonical = canonical_bytes_for_identity(&identity).map_err(|_| make_err())?;
288 verifying_key.verify(&canonical, &signature)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use ed25519_dalek::SigningKey;
295
296 fn make_test_keypair(agent_id: &str) -> AgentKeypair {
297 let seed = [42u8; ed25519_dalek::SECRET_KEY_LENGTH];
299 let signing_key = SigningKey::from_bytes(&seed);
300 AgentKeypair {
301 agent_id: agent_id.to_string(),
302 public: signing_key.verifying_key(),
303 private: Some(signing_key),
304 }
305 }
306
307 fn make_public_only_keypair(agent_id: &str) -> AgentKeypair {
308 let kp = make_test_keypair(agent_id);
309 AgentKeypair {
310 agent_id: kp.agent_id,
311 public: kp.public,
312 private: None,
313 }
314 }
315
316 fn fixed_timestamp() -> &'static str {
317 "2026-05-23T16:30:22Z"
318 }
319
320 #[test]
331 fn canonical_bytes_are_deterministic() {
332 let id = DaemonIdentityToSign {
333 schema_version: "vTEST_BASE",
334 daemon_id: "ai:nhi@host",
335 public_key: "abc123",
336 signed_at: fixed_timestamp(),
337 };
338 let bytes_a = canonical_bytes_for_identity(&id).unwrap();
339 let bytes_b = canonical_bytes_for_identity(&id).unwrap();
340 assert_eq!(
341 bytes_a, bytes_b,
342 "canonical bytes must be deterministic across calls"
343 );
344 }
345
346 #[test]
347 fn canonical_bytes_diverge_on_any_field_change() {
348 let base = DaemonIdentityToSign {
349 schema_version: "vTEST_BASE",
350 daemon_id: "ai:nhi@host",
351 public_key: "abc123",
352 signed_at: fixed_timestamp(),
353 };
354 let base_bytes = canonical_bytes_for_identity(&base).unwrap();
355
356 let cases = [
357 DaemonIdentityToSign {
358 schema_version: "vTEST_CHANGED",
359 ..base.clone()
360 },
361 DaemonIdentityToSign {
362 daemon_id: "ai:other@host",
363 ..base.clone()
364 },
365 DaemonIdentityToSign {
366 public_key: "abc124",
367 ..base.clone()
368 },
369 DaemonIdentityToSign {
370 signed_at: "2026-05-24T00:00:00Z",
371 ..base.clone()
372 },
373 ];
374
375 for (i, mutated) in cases.iter().enumerate() {
376 let mutated_bytes = canonical_bytes_for_identity(mutated).unwrap();
377 assert_ne!(
378 base_bytes, mutated_bytes,
379 "canonical bytes must diverge when field {i} changes"
380 );
381 }
382 }
383
384 #[test]
387 fn build_signed_identity_returns_none_when_keypair_absent() {
388 let result = build_signed_identity(None, fixed_timestamp()).unwrap();
389 assert!(result.is_none(), "absent keypair must yield None");
390 }
391
392 #[test]
393 fn build_signed_identity_returns_none_when_private_key_missing() {
394 let kp = make_public_only_keypair("ai:nhi@host");
395 let result = build_signed_identity(Some(&kp), fixed_timestamp()).unwrap();
396 assert!(result.is_none(), "public-only keypair must yield None");
397 }
398
399 #[test]
400 fn build_signed_identity_returns_well_formed_block_when_signing_key_present() {
401 let kp = make_test_keypair("ai:nhi@host");
402 let block = build_signed_identity(Some(&kp), fixed_timestamp())
403 .unwrap()
404 .expect("signing keypair must yield Some");
405
406 let obj = block.as_object().expect("block must be a JSON object");
407 assert!(obj.get("schema_version").and_then(Value::as_str).is_some());
408 assert!(obj.get("daemon_id").and_then(Value::as_str).is_some());
409 assert!(obj.get("public_key").and_then(Value::as_str).is_some());
410 assert!(obj.get("signed_at").and_then(Value::as_str).is_some());
411 assert!(obj.get("signature").and_then(Value::as_str).is_some());
412
413 assert_eq!(obj["daemon_id"], json!("ai:nhi@host"));
414 assert_eq!(obj["signed_at"], json!(fixed_timestamp()));
415 }
416
417 #[test]
418 fn build_signed_identity_carries_current_schema_version() {
419 let kp = make_test_keypair("ai:nhi@host");
420 let block = build_signed_identity(Some(&kp), fixed_timestamp())
421 .unwrap()
422 .expect("signing keypair must yield Some");
423 let schema = block["schema_version"].as_str().unwrap();
424 let expected = format!("v{}", current_schema_version());
425 assert_eq!(
426 schema, expected,
427 "schema_version must match CURRENT_SCHEMA_VERSION constant"
428 );
429 }
430
431 #[test]
432 fn build_signed_identity_carries_public_key_base64() {
433 let kp = make_test_keypair("ai:nhi@host");
434 let block = build_signed_identity(Some(&kp), fixed_timestamp())
435 .unwrap()
436 .expect("signing keypair must yield Some");
437 let pk_b64 = block["public_key"].as_str().unwrap();
438 assert_eq!(
439 pk_b64,
440 kp.public_base64(),
441 "public_key must round-trip kp.public_base64()"
442 );
443 }
444
445 #[test]
448 fn signed_identity_verifies_against_embedded_public_key() {
449 let kp = make_test_keypair("ai:nhi@host");
450 let block = build_signed_identity(Some(&kp), fixed_timestamp())
451 .unwrap()
452 .expect("signing keypair must yield Some");
453 verify_signed_identity(&block).expect("signature must verify");
454 }
455
456 #[test]
457 fn signed_identity_round_trips_across_many_signers() {
458 for byte in 0u8..16 {
461 let seed = [byte; ed25519_dalek::SECRET_KEY_LENGTH];
462 let signing_key = SigningKey::from_bytes(&seed);
463 let kp = AgentKeypair {
464 agent_id: format!("ai:agent-{byte}@host"),
465 public: signing_key.verifying_key(),
466 private: Some(signing_key),
467 };
468 let block = build_signed_identity(Some(&kp), fixed_timestamp())
469 .unwrap()
470 .expect("signing keypair must yield Some");
471 verify_signed_identity(&block)
472 .unwrap_or_else(|_| panic!("signature {byte} must verify"));
473 }
474 }
475
476 #[test]
479 fn tampered_daemon_id_fails_verification() {
480 let kp = make_test_keypair("ai:nhi@host");
481 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
482 .unwrap()
483 .expect("signing keypair must yield Some");
484 block["daemon_id"] = json!("ai:adversary@host");
485 assert!(
486 verify_signed_identity(&block).is_err(),
487 "tampered daemon_id must fail verification"
488 );
489 }
490
491 #[test]
492 fn tampered_schema_version_fails_verification() {
493 let kp = make_test_keypair("ai:nhi@host");
494 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
495 .unwrap()
496 .expect("signing keypair must yield Some");
497 block["schema_version"] = json!("v99");
498 assert!(
499 verify_signed_identity(&block).is_err(),
500 "tampered schema_version must fail verification"
501 );
502 }
503
504 #[test]
505 fn tampered_signed_at_fails_verification() {
506 let kp = make_test_keypair("ai:nhi@host");
507 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
508 .unwrap()
509 .expect("signing keypair must yield Some");
510 block["signed_at"] = json!("2099-12-31T23:59:59Z");
511 assert!(
512 verify_signed_identity(&block).is_err(),
513 "tampered signed_at must fail verification"
514 );
515 }
516
517 #[test]
518 fn tampered_signature_byte_fails_verification() {
519 let kp = make_test_keypair("ai:nhi@host");
520 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
521 .unwrap()
522 .expect("signing keypair must yield Some");
523 let original_sig = block["signature"].as_str().unwrap();
524 let mut chars: Vec<char> = original_sig.chars().collect();
526 let mid = chars.len() / 2;
527 chars[mid] = if chars[mid] == 'A' { 'B' } else { 'A' };
528 let tampered: String = chars.into_iter().collect();
529 block["signature"] = json!(tampered);
530 assert!(
531 verify_signed_identity(&block).is_err(),
532 "tampered signature must fail verification"
533 );
534 }
535
536 #[test]
537 fn substituted_public_key_fails_verification() {
538 let kp_a = make_test_keypair("ai:nhi@host");
539 let mut block = build_signed_identity(Some(&kp_a), fixed_timestamp())
540 .unwrap()
541 .expect("signing keypair must yield Some");
542
543 let seed_b = [99u8; ed25519_dalek::SECRET_KEY_LENGTH];
547 let kp_b_signing = SigningKey::from_bytes(&seed_b);
548 let kp_b_public_b64 = URL_SAFE_NO_PAD.encode(kp_b_signing.verifying_key().to_bytes());
549 block["public_key"] = json!(kp_b_public_b64);
550
551 assert!(
552 verify_signed_identity(&block).is_err(),
553 "substituted public key (without re-signing) must fail verification"
554 );
555 }
556
557 #[test]
560 fn verify_rejects_non_object_input() {
561 assert!(verify_signed_identity(&json!("not an object")).is_err());
562 assert!(verify_signed_identity(&json!(42)).is_err());
563 assert!(verify_signed_identity(&json!([1, 2, 3])).is_err());
564 assert!(verify_signed_identity(&json!(null)).is_err());
565 }
566
567 #[test]
568 fn verify_rejects_missing_required_field() {
569 let kp = make_test_keypair("ai:nhi@host");
570 let full_block = build_signed_identity(Some(&kp), fixed_timestamp())
571 .unwrap()
572 .expect("signing keypair must yield Some");
573
574 for field in &[
575 "schema_version",
576 "daemon_id",
577 "public_key",
578 "signed_at",
579 "signature",
580 ] {
581 let mut block = full_block.clone();
582 block.as_object_mut().unwrap().remove(*field);
583 assert!(
584 verify_signed_identity(&block).is_err(),
585 "missing field {field} must cause verification failure"
586 );
587 }
588 }
589
590 #[test]
591 fn verify_rejects_invalid_base64() {
592 let kp = make_test_keypair("ai:nhi@host");
593 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
594 .unwrap()
595 .expect("signing keypair must yield Some");
596 block["public_key"] = json!("@@@not-base64@@@");
597 assert!(verify_signed_identity(&block).is_err());
598
599 let mut block2 = build_signed_identity(Some(&kp), fixed_timestamp())
600 .unwrap()
601 .expect("signing keypair must yield Some");
602 block2["signature"] = json!("@@@not-base64@@@");
603 assert!(verify_signed_identity(&block2).is_err());
604 }
605
606 #[test]
607 fn verify_rejects_wrong_length_public_key() {
608 let kp = make_test_keypair("ai:nhi@host");
609 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
610 .unwrap()
611 .expect("signing keypair must yield Some");
612 block["public_key"] = json!(URL_SAFE_NO_PAD.encode([0u8; 16]));
614 assert!(verify_signed_identity(&block).is_err());
615 }
616
617 #[test]
618 fn verify_rejects_wrong_length_signature() {
619 let kp = make_test_keypair("ai:nhi@host");
620 let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
621 .unwrap()
622 .expect("signing keypair must yield Some");
623 block["signature"] = json!(URL_SAFE_NO_PAD.encode([0u8; 32]));
625 assert!(verify_signed_identity(&block).is_err());
626 }
627
628 #[test]
631 fn build_signed_identity_completes_under_10ms_one_iteration() {
632 let kp = make_test_keypair("ai:nhi@host");
633 let start = std::time::Instant::now();
634 let _ = build_signed_identity(Some(&kp), fixed_timestamp()).unwrap();
635 let elapsed = start.elapsed();
636 assert!(
640 elapsed.as_millis() < 10,
641 "single sign must be sub-10ms (was {elapsed:?})"
642 );
643 }
644}