use crate::models::field_names;
use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ed25519_dalek::{Signature, Signer, Verifier, VerifyingKey};
use serde_json::{Value, json};
use crate::identity::keypair::AgentKeypair;
use crate::storage::migrations::current_schema_version;
const PUBLIC_KEY_FIELD: &str = "public_key";
#[derive(Debug, Clone)]
pub struct DaemonIdentityToSign<'a> {
pub schema_version: &'a str,
pub daemon_id: &'a str,
pub public_key: &'a str,
pub signed_at: &'a str,
}
pub fn canonical_bytes_for_identity(identity: &DaemonIdentityToSign<'_>) -> Result<Vec<u8>> {
let canonical = json!({
(field_names::SCHEMA_VERSION): identity.schema_version,
"daemon_id": identity.daemon_id,
(PUBLIC_KEY_FIELD): identity.public_key,
"signed_at": identity.signed_at,
});
serde_json::to_vec(&canonical)
.context("server_identity::canonical_bytes_for_identity: serialize")
}
pub fn build_signed_identity(
keypair: Option<&AgentKeypair>,
now_rfc3339: &str,
) -> Result<Option<Value>> {
let Some(kp) = keypair else {
return Ok(None);
};
let Some(signing_key) = kp.private.as_ref() else {
return Ok(None);
};
let schema_version = format!("v{}", current_schema_version());
let public_key = kp.public_base64();
let identity = DaemonIdentityToSign {
schema_version: &schema_version,
daemon_id: &kp.agent_id,
public_key: &public_key,
signed_at: now_rfc3339,
};
let canonical = canonical_bytes_for_identity(&identity)?;
let signature: Signature = signing_key.sign(&canonical);
let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
Ok(Some(json!({
(field_names::SCHEMA_VERSION): schema_version,
"daemon_id": kp.agent_id,
(PUBLIC_KEY_FIELD): public_key,
"signed_at": now_rfc3339,
"signature": sig_b64,
})))
}
pub fn verify_signed_identity(block: &Value) -> Result<(), ed25519_dalek::SignatureError> {
let make_err = ed25519_dalek::SignatureError::new;
let obj = block.as_object().ok_or_else(make_err)?;
let schema_version = obj
.get(field_names::SCHEMA_VERSION)
.and_then(Value::as_str)
.ok_or_else(make_err)?;
let daemon_id = obj
.get("daemon_id")
.and_then(Value::as_str)
.ok_or_else(make_err)?;
let public_key_b64 = obj
.get(PUBLIC_KEY_FIELD)
.and_then(Value::as_str)
.ok_or_else(make_err)?;
let signed_at = obj
.get("signed_at")
.and_then(Value::as_str)
.ok_or_else(make_err)?;
let signature_b64 = obj
.get("signature")
.and_then(Value::as_str)
.ok_or_else(make_err)?;
let public_key_bytes = URL_SAFE_NO_PAD
.decode(public_key_b64)
.map_err(|_| make_err())?;
let signature_bytes = URL_SAFE_NO_PAD
.decode(signature_b64)
.map_err(|_| make_err())?;
if public_key_bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
return Err(make_err());
}
if signature_bytes.len() != ed25519_dalek::SIGNATURE_LENGTH {
return Err(make_err());
}
let mut pk_arr = [0u8; ed25519_dalek::PUBLIC_KEY_LENGTH];
pk_arr.copy_from_slice(&public_key_bytes);
let mut sig_arr = [0u8; ed25519_dalek::SIGNATURE_LENGTH];
sig_arr.copy_from_slice(&signature_bytes);
let verifying_key = VerifyingKey::from_bytes(&pk_arr).map_err(|_| make_err())?;
let signature = Signature::from_bytes(&sig_arr);
let identity = DaemonIdentityToSign {
schema_version,
daemon_id,
public_key: public_key_b64,
signed_at,
};
let canonical = canonical_bytes_for_identity(&identity).map_err(|_| make_err())?;
verifying_key.verify(&canonical, &signature)
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::SigningKey;
fn make_test_keypair(agent_id: &str) -> AgentKeypair {
let seed = [42u8; ed25519_dalek::SECRET_KEY_LENGTH];
let signing_key = SigningKey::from_bytes(&seed);
AgentKeypair {
agent_id: agent_id.to_string(),
public: signing_key.verifying_key(),
private: Some(signing_key),
}
}
fn make_public_only_keypair(agent_id: &str) -> AgentKeypair {
let kp = make_test_keypair(agent_id);
AgentKeypair {
agent_id: kp.agent_id,
public: kp.public,
private: None,
}
}
fn fixed_timestamp() -> &'static str {
"2026-05-23T16:30:22Z"
}
#[test]
fn canonical_bytes_are_deterministic() {
let id = DaemonIdentityToSign {
schema_version: "vTEST_BASE",
daemon_id: "ai:nhi@host",
public_key: "abc123",
signed_at: fixed_timestamp(),
};
let bytes_a = canonical_bytes_for_identity(&id).unwrap();
let bytes_b = canonical_bytes_for_identity(&id).unwrap();
assert_eq!(
bytes_a, bytes_b,
"canonical bytes must be deterministic across calls"
);
}
#[test]
fn canonical_bytes_diverge_on_any_field_change() {
let base = DaemonIdentityToSign {
schema_version: "vTEST_BASE",
daemon_id: "ai:nhi@host",
public_key: "abc123",
signed_at: fixed_timestamp(),
};
let base_bytes = canonical_bytes_for_identity(&base).unwrap();
let cases = [
DaemonIdentityToSign {
schema_version: "vTEST_CHANGED",
..base.clone()
},
DaemonIdentityToSign {
daemon_id: "ai:other@host",
..base.clone()
},
DaemonIdentityToSign {
public_key: "abc124",
..base.clone()
},
DaemonIdentityToSign {
signed_at: "2026-05-24T00:00:00Z",
..base.clone()
},
];
for (i, mutated) in cases.iter().enumerate() {
let mutated_bytes = canonical_bytes_for_identity(mutated).unwrap();
assert_ne!(
base_bytes, mutated_bytes,
"canonical bytes must diverge when field {i} changes"
);
}
}
#[test]
fn build_signed_identity_returns_none_when_keypair_absent() {
let result = build_signed_identity(None, fixed_timestamp()).unwrap();
assert!(result.is_none(), "absent keypair must yield None");
}
#[test]
fn build_signed_identity_returns_none_when_private_key_missing() {
let kp = make_public_only_keypair("ai:nhi@host");
let result = build_signed_identity(Some(&kp), fixed_timestamp()).unwrap();
assert!(result.is_none(), "public-only keypair must yield None");
}
#[test]
fn build_signed_identity_returns_well_formed_block_when_signing_key_present() {
let kp = make_test_keypair("ai:nhi@host");
let block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
let obj = block.as_object().expect("block must be a JSON object");
assert!(obj.get("schema_version").and_then(Value::as_str).is_some());
assert!(obj.get("daemon_id").and_then(Value::as_str).is_some());
assert!(obj.get("public_key").and_then(Value::as_str).is_some());
assert!(obj.get("signed_at").and_then(Value::as_str).is_some());
assert!(obj.get("signature").and_then(Value::as_str).is_some());
assert_eq!(obj["daemon_id"], json!("ai:nhi@host"));
assert_eq!(obj["signed_at"], json!(fixed_timestamp()));
}
#[test]
fn build_signed_identity_carries_current_schema_version() {
let kp = make_test_keypair("ai:nhi@host");
let block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
let schema = block["schema_version"].as_str().unwrap();
let expected = format!("v{}", current_schema_version());
assert_eq!(
schema, expected,
"schema_version must match CURRENT_SCHEMA_VERSION constant"
);
}
#[test]
fn build_signed_identity_carries_public_key_base64() {
let kp = make_test_keypair("ai:nhi@host");
let block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
let pk_b64 = block["public_key"].as_str().unwrap();
assert_eq!(
pk_b64,
kp.public_base64(),
"public_key must round-trip kp.public_base64()"
);
}
#[test]
fn signed_identity_verifies_against_embedded_public_key() {
let kp = make_test_keypair("ai:nhi@host");
let block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
verify_signed_identity(&block).expect("signature must verify");
}
#[test]
fn signed_identity_round_trips_across_many_signers() {
for byte in 0u8..16 {
let seed = [byte; ed25519_dalek::SECRET_KEY_LENGTH];
let signing_key = SigningKey::from_bytes(&seed);
let kp = AgentKeypair {
agent_id: format!("ai:agent-{byte}@host"),
public: signing_key.verifying_key(),
private: Some(signing_key),
};
let block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
verify_signed_identity(&block)
.unwrap_or_else(|_| panic!("signature {byte} must verify"));
}
}
#[test]
fn tampered_daemon_id_fails_verification() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block["daemon_id"] = json!("ai:adversary@host");
assert!(
verify_signed_identity(&block).is_err(),
"tampered daemon_id must fail verification"
);
}
#[test]
fn tampered_schema_version_fails_verification() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block["schema_version"] = json!("v99");
assert!(
verify_signed_identity(&block).is_err(),
"tampered schema_version must fail verification"
);
}
#[test]
fn tampered_signed_at_fails_verification() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block["signed_at"] = json!("2099-12-31T23:59:59Z");
assert!(
verify_signed_identity(&block).is_err(),
"tampered signed_at must fail verification"
);
}
#[test]
fn tampered_signature_byte_fails_verification() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
let original_sig = block["signature"].as_str().unwrap();
let mut chars: Vec<char> = original_sig.chars().collect();
let mid = chars.len() / 2;
chars[mid] = if chars[mid] == 'A' { 'B' } else { 'A' };
let tampered: String = chars.into_iter().collect();
block["signature"] = json!(tampered);
assert!(
verify_signed_identity(&block).is_err(),
"tampered signature must fail verification"
);
}
#[test]
fn substituted_public_key_fails_verification() {
let kp_a = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp_a), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
let seed_b = [99u8; ed25519_dalek::SECRET_KEY_LENGTH];
let kp_b_signing = SigningKey::from_bytes(&seed_b);
let kp_b_public_b64 = URL_SAFE_NO_PAD.encode(kp_b_signing.verifying_key().to_bytes());
block["public_key"] = json!(kp_b_public_b64);
assert!(
verify_signed_identity(&block).is_err(),
"substituted public key (without re-signing) must fail verification"
);
}
#[test]
fn verify_rejects_non_object_input() {
assert!(verify_signed_identity(&json!("not an object")).is_err());
assert!(verify_signed_identity(&json!(42)).is_err());
assert!(verify_signed_identity(&json!([1, 2, 3])).is_err());
assert!(verify_signed_identity(&json!(null)).is_err());
}
#[test]
fn verify_rejects_missing_required_field() {
let kp = make_test_keypair("ai:nhi@host");
let full_block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
for field in &[
"schema_version",
"daemon_id",
"public_key",
"signed_at",
"signature",
] {
let mut block = full_block.clone();
block.as_object_mut().unwrap().remove(*field);
assert!(
verify_signed_identity(&block).is_err(),
"missing field {field} must cause verification failure"
);
}
}
#[test]
fn verify_rejects_invalid_base64() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block["public_key"] = json!("@@@not-base64@@@");
assert!(verify_signed_identity(&block).is_err());
let mut block2 = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block2["signature"] = json!("@@@not-base64@@@");
assert!(verify_signed_identity(&block2).is_err());
}
#[test]
fn verify_rejects_wrong_length_public_key() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block["public_key"] = json!(URL_SAFE_NO_PAD.encode([0u8; 16]));
assert!(verify_signed_identity(&block).is_err());
}
#[test]
fn verify_rejects_wrong_length_signature() {
let kp = make_test_keypair("ai:nhi@host");
let mut block = build_signed_identity(Some(&kp), fixed_timestamp())
.unwrap()
.expect("signing keypair must yield Some");
block["signature"] = json!(URL_SAFE_NO_PAD.encode([0u8; 32]));
assert!(verify_signed_identity(&block).is_err());
}
#[test]
fn build_signed_identity_completes_under_10ms_one_iteration() {
let kp = make_test_keypair("ai:nhi@host");
let start = std::time::Instant::now();
let _ = build_signed_identity(Some(&kp), fixed_timestamp()).unwrap();
let elapsed = start.elapsed();
assert!(
elapsed.as_millis() < 10,
"single sign must be sub-10ms (was {elapsed:?})"
);
}
}