use ed25519_dalek::{SECRET_KEY_LENGTH, SigningKey, VerifyingKey};
use rand_core::OsRng;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[derive(Debug, thiserror::Error)]
pub enum IdentityError {
#[error("identity files not found")]
NotFound,
#[error("refusing to overwrite an existing identity key at {0}")]
Exists(String),
#[error("identity key exists but is not readable: {0}")]
Denied(String),
#[error("io error: {0}")]
Io(#[from] io::Error),
#[error("invalid key material: {0}")]
InvalidKey(String),
#[error("multibase decode error: {0}")]
Multibase(#[from] multibase::Error),
}
#[derive(Clone)]
pub struct AgentIdentity {
signing: SigningKey,
}
impl std::fmt::Debug for AgentIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentIdentity")
.field("verifying_key", &self.signing.verifying_key())
.finish()
}
}
pub fn private_key_dir(dir: &Path) -> PathBuf {
let is_agent_home = dir
.parent()
.and_then(|p| p.file_name())
.is_some_and(|n| n == "agents");
if !is_agent_home {
return dir.to_path_buf();
}
let (Some(name), Some(mur_home)) = (dir.file_name(), dir.parent().and_then(|p| p.parent()))
else {
return dir.to_path_buf();
};
mur_home.join("keys").join(name)
}
pub fn migrate_private_key(agent_dir: &Path) -> Result<bool, IdentityError> {
let key_dir = private_key_dir(agent_dir);
if key_dir == agent_dir {
return Ok(false); }
let legacy = agent_dir.join("identity.key");
let target = key_dir.join("identity.key");
let legacy_bytes = match fs::read(&legacy) {
Ok(b) => b,
Err(_) => return Ok(false),
};
if let Ok(existing) = fs::read(&target) {
if existing == legacy_bytes {
let _ = fs::remove_file(&legacy);
return Ok(false);
}
return Err(IdentityError::Exists(format!(
"{} already holds a DIFFERENT key than {}; refusing to migrate — resolve by hand, because picking one silently changes this agent's identity",
target.display(),
legacy.display()
)));
}
fs::create_dir_all(&key_dir)?;
fs::write(&target, &legacy_bytes)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&target, fs::Permissions::from_mode(0o600))?;
}
fs::remove_file(&legacy)?;
let legacy_prev = agent_dir.join("identity.key.prev");
if let Ok(prev) = fs::read(&legacy_prev) {
let target_prev = key_dir.join("identity.key.prev");
if fs::metadata(&target_prev).is_err() {
fs::write(&target_prev, &prev)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&target_prev, fs::Permissions::from_mode(0o600))?;
}
}
let _ = fs::remove_file(&legacy_prev);
}
Ok(true)
}
impl AgentIdentity {
pub fn generate() -> Self {
Self {
signing: SigningKey::generate(&mut OsRng),
}
}
pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
fs::create_dir_all(dir)?;
let key_dir = private_key_dir(dir);
fs::create_dir_all(&key_dir)?;
let priv_path = key_dir.join("identity.key");
let pub_path = dir.join("identity.pub");
if priv_path.exists() {
return Err(IdentityError::Exists(priv_path.display().to_string()));
}
fs::write(&priv_path, self.signing.to_bytes())?;
#[cfg(unix)]
{
let mut perms = fs::metadata(&priv_path)?.permissions();
perms.set_mode(0o600);
fs::set_permissions(&priv_path, perms)?;
}
let pub_text = encode_pubkey(&self.signing.verifying_key());
fs::write(&pub_path, pub_text)?;
Ok(())
}
pub fn load(dir: &Path) -> Result<Self, IdentityError> {
let priv_path = private_key_dir(dir).join("identity.key");
if let Err(e) = fs::metadata(&priv_path) {
return Err(match e.kind() {
io::ErrorKind::NotFound => IdentityError::NotFound,
_ => IdentityError::Denied(format!("{}: {e}", priv_path.display())),
});
}
let bytes = fs::read(&priv_path).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => IdentityError::NotFound,
io::ErrorKind::PermissionDenied => {
IdentityError::Denied(format!("{}: {e}", priv_path.display()))
}
_ => IdentityError::Io(e),
})?;
if bytes.len() != SECRET_KEY_LENGTH {
return Err(IdentityError::InvalidKey(format!(
"expected {SECRET_KEY_LENGTH} bytes, got {}",
bytes.len()
)));
}
let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
let signing = SigningKey::from_bytes(&arr);
let pub_path = dir.join("identity.pub");
if pub_path.exists() {
let text = fs::read_to_string(&pub_path)?;
let loaded_pub = decode_pubkey(text.trim())?;
if loaded_pub != *signing.verifying_key().as_bytes() {
return Err(IdentityError::InvalidKey(
"identity.pub does not match identity.key".into(),
));
}
}
Ok(Self { signing })
}
pub fn load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
let path = dir.join("identity.pub");
if !path.exists() {
return Err(IdentityError::NotFound);
}
decode_pubkey(fs::read_to_string(&path)?.trim())
}
pub fn signing_key(&self) -> &SigningKey {
&self.signing
}
pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
use ed25519_dalek::Signer;
self.signing.sign(msg).to_bytes()
}
pub fn sign_multibase(&self, msg: &[u8]) -> String {
multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
}
pub fn verifying_key(&self) -> VerifyingKey {
self.signing.verifying_key()
}
pub fn verifying_key_bytes(&self) -> [u8; 32] {
*self.signing.verifying_key().as_bytes()
}
pub fn pubkey_text(&self) -> String {
encode_pubkey(&self.signing.verifying_key())
}
pub fn public_key_multibase(&self) -> String {
encode_pubkey(&self.signing.verifying_key())
}
pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
let scalar_bytes = self.signing.to_scalar_bytes();
x25519_dalek::StaticSecret::from(scalar_bytes)
}
}
pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
return false;
};
let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
return false;
};
let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
return false;
};
vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
.is_ok()
}
pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
VerifyingKey::from_bytes(bytes).is_ok()
}
pub fn encode_pubkey(key: &VerifyingKey) -> String {
multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
}
pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
let (_base, bytes) = multibase::decode(text)?;
if bytes.len() != 32 {
return Err(IdentityError::InvalidKey(format!(
"pubkey must be 32 bytes, got {}",
bytes.len()
)));
}
let mut out = [0u8; 32];
out.copy_from_slice(&bytes);
Ok(out)
}
pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
let point = compressed.decompress()?;
Some(point.to_montgomery().to_bytes())
}
pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
let ed = decode_pubkey(text)?;
ed25519_pub_to_x25519(&ed)
.ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
}
pub fn default_dir(agent_home: &Path) -> PathBuf {
agent_home.to_path_buf()
}
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RotationReason {
Scheduled,
SuspectCompromise,
OwnerChange,
Emergency,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RotationAttestation {
pub schema: u32,
pub uuid: String,
pub algorithm: String,
pub old_pubkey: String,
pub new_pubkey: String,
pub old_key_version: u32,
pub new_key_version: u32,
pub rotated_at: String,
pub reason: RotationReason,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub signature: String,
#[serde(default, skip_serializing_if = "is_false")]
pub bootstrap: bool,
}
fn is_false(b: &bool) -> bool {
!*b
}
impl RotationAttestation {
pub fn new(
uuid: impl Into<String>,
old_pubkey: impl Into<String>,
new_pubkey: impl Into<String>,
old_key_version: u32,
new_key_version: u32,
rotated_at: impl Into<String>,
reason: RotationReason,
) -> Self {
Self {
schema: 1,
uuid: uuid.into(),
algorithm: "ed25519".into(),
old_pubkey: old_pubkey.into(),
new_pubkey: new_pubkey.into(),
old_key_version,
new_key_version,
rotated_at: rotated_at.into(),
reason,
signature: String::new(),
bootstrap: false,
}
}
pub fn into_bootstrap(mut self) -> Self {
self.bootstrap = true;
self.old_pubkey = String::new();
self.signature = String::new();
self
}
pub fn canonical_bytes(&self) -> Vec<u8> {
let mut clone = self.clone();
clone.signature = String::new();
canonical_json(&clone)
}
pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
use ed25519_dalek::Signer;
let sig = signing.sign(&self.canonical_bytes());
self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
}
pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
if self.bootstrap {
return Ok(());
}
if self.signature.is_empty() {
return Err(IdentityError::InvalidKey(
"attestation signature is empty".into(),
));
}
let pub_bytes = decode_pubkey(old_pubkey)?;
let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
.map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
let (_base, sig_bytes) = multibase::decode(&self.signature)?;
let sig_arr: [u8; 64] = sig_bytes
.as_slice()
.try_into()
.map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
verifying
.verify_strict(&self.canonical_bytes(), &sig)
.map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
Ok(())
}
pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
if self.reason == RotationReason::Emergency && self.signature.is_empty() {
return Ok(());
}
self.verify(old_pubkey)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct ChainOptions {
pub allow_emergency: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChainOutcome {
pub head_key_version: u32,
pub head_pubkey: String,
pub length: usize,
}
#[derive(Debug)]
pub enum ChainError {
MissingBootstrap,
VersionSkip { expected: u32, got: u32 },
PubkeyDiscontinuity { at_version: u32 },
DuplicateVersion(u32),
BadSignature { at_version: u32, detail: String },
EmergencyDisallowed { at_version: u32 },
}
impl std::fmt::Display for ChainError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingBootstrap => {
write!(
f,
"chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
)
}
Self::VersionSkip { expected, got } => {
write!(f, "version skip: expected {expected}, got {got}")
}
Self::PubkeyDiscontinuity { at_version } => {
write!(
f,
"pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
)
}
Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
Self::BadSignature { at_version, detail } => {
write!(f, "bad signature at key_version {at_version}: {detail}")
}
Self::EmergencyDisallowed { at_version } => {
write!(
f,
"emergency attestation at key_version {at_version} requires allow_emergency=true"
)
}
}
}
}
impl std::error::Error for ChainError {}
pub fn verify_chain(
chain: &[RotationAttestation],
opts: ChainOptions,
) -> std::result::Result<ChainOutcome, ChainError> {
if chain.is_empty() {
return Err(ChainError::MissingBootstrap);
}
let first = &chain[0];
if !first.bootstrap || first.new_key_version != 0 {
return Err(ChainError::MissingBootstrap);
}
let mut prev_pubkey = first.new_pubkey.clone();
let mut prev_version = 0u32;
let mut seen_versions = std::collections::HashSet::new();
seen_versions.insert(0u32);
for (i, a) in chain.iter().enumerate().skip(1) {
if !seen_versions.insert(a.new_key_version) {
return Err(ChainError::DuplicateVersion(a.new_key_version));
}
let expected = prev_version + 1;
if a.old_key_version != prev_version || a.new_key_version != expected {
return Err(ChainError::VersionSkip {
expected,
got: a.new_key_version,
});
}
if a.old_pubkey != prev_pubkey {
return Err(ChainError::PubkeyDiscontinuity {
at_version: a.new_key_version,
});
}
if a.reason == RotationReason::Emergency {
if !opts.allow_emergency {
return Err(ChainError::EmergencyDisallowed {
at_version: a.new_key_version,
});
}
if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
return Err(ChainError::BadSignature {
at_version: a.new_key_version,
detail: e.to_string(),
});
}
} else if let Err(e) = a.verify(&a.old_pubkey) {
return Err(ChainError::BadSignature {
at_version: a.new_key_version,
detail: e.to_string(),
});
}
prev_pubkey = a.new_pubkey.clone();
prev_version = a.new_key_version;
let _ = i; }
Ok(ChainOutcome {
head_key_version: prev_version,
head_pubkey: prev_pubkey,
length: chain.len(),
})
}
fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
let v: serde_json::Value =
serde_json::to_value(value).expect("serialize should not fail for our types");
let mut out = Vec::new();
write_canonical(&mut out, &v);
out
}
fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
use serde_json::Value;
match v {
Value::Null => out.extend_from_slice(b"null"),
Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
Value::String(s) => {
let escaped = serde_json::to_string(s).unwrap();
out.extend_from_slice(escaped.as_bytes());
}
Value::Array(arr) => {
out.push(b'[');
for (i, item) in arr.iter().enumerate() {
if i > 0 {
out.push(b',');
}
write_canonical(out, item);
}
out.push(b']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
out.push(b'{');
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(b',');
}
let kesc = serde_json::to_string(k).unwrap();
out.extend_from_slice(kesc.as_bytes());
out.push(b':');
write_canonical(out, &map[*k]);
}
out.push(b'}');
}
}
}
#[cfg(test)]
mod identity_readability_tests {
use super::*;
#[cfg(unix)]
#[test]
fn an_unreadable_key_is_denied_not_notfound() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
AgentIdentity::generate().save(dir.path()).unwrap();
let key = dir.path().join("identity.key");
assert!(AgentIdentity::load(dir.path()).is_ok());
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o000)).unwrap();
let unreadable = AgentIdentity::load(dir.path()).unwrap_err();
std::fs::set_permissions(&key, std::fs::Permissions::from_mode(0o600)).unwrap();
assert!(
matches!(unreadable, IdentityError::Denied(_)),
"an unreadable key must be Denied, got {unreadable:?}"
);
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
let unstattable = AgentIdentity::load(dir.path()).unwrap_err();
let exists_lies = !key.exists();
std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(
exists_lies,
"precondition: Path::exists() must be answering false here, or this \
case is not reproducing a sandbox deny"
);
assert!(
matches!(unstattable, IdentityError::Denied(_)),
"a key that cannot be STATted must be Denied, not NotFound, got {unstattable:?}"
);
}
#[test]
fn an_agent_key_moves_and_the_public_half_stays() {
let tmp = tempfile::tempdir().unwrap();
let mur = tmp.path();
let agent = mur.join("agents").join("pm");
AgentIdentity::generate().save(&agent).unwrap();
assert!(
mur.join("keys").join("pm").join("identity.key").exists(),
"private key must live under keys/"
);
assert!(
!agent.join("identity.key").exists(),
"the agents tree must hold no private key"
);
assert!(
agent.join("identity.pub").exists(),
"the public half must stay where peers read it"
);
}
#[test]
fn non_agent_identities_are_not_remapped() {
let tmp = tempfile::tempdir().unwrap();
let mur = tmp.path();
for dir in [
mur.to_path_buf(), mur.join("commander"), mur.join("publisher"), ] {
assert_eq!(
private_key_dir(&dir),
dir,
"{} must not be remapped",
dir.display()
);
AgentIdentity::generate().save(&dir).unwrap();
assert!(
dir.join("identity.key").exists(),
"{} lost its key to the remap",
dir.display()
);
}
}
#[test]
fn a_legacy_key_loads_only_after_migration() {
let tmp = tempfile::tempdir().unwrap();
let agent = tmp.path().join("agents").join("legacy");
std::fs::create_dir_all(&agent).unwrap();
let id = AgentIdentity::generate();
std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
assert!(
matches!(
AgentIdentity::load(&agent).unwrap_err(),
IdentityError::NotFound
),
"a key in the legacy location must not be silently honoured"
);
assert!(migrate_private_key(&agent).unwrap());
assert_eq!(
AgentIdentity::load(&agent).unwrap().pubkey_text(),
id.pubkey_text()
);
}
#[test]
fn the_new_location_wins_over_a_leftover_legacy_key() {
let tmp = tempfile::tempdir().unwrap();
let mur = tmp.path();
let agent = mur.join("agents").join("dual");
let current = AgentIdentity::generate();
current.save(&agent).unwrap();
let stale = AgentIdentity::generate();
std::fs::create_dir_all(&agent).unwrap();
std::fs::write(agent.join("identity.key"), stale.signing.to_bytes()).unwrap();
let loaded = AgentIdentity::load(&agent).unwrap();
assert_eq!(
loaded.pubkey_text(),
current.pubkey_text(),
"the migrated key must win over the leftover"
);
}
#[test]
fn migration_moves_the_key_out_of_the_agents_tree() {
let tmp = tempfile::tempdir().unwrap();
let mur = tmp.path();
let agent = mur.join("agents").join("pm");
std::fs::create_dir_all(&agent).unwrap();
let id = AgentIdentity::generate();
std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
assert!(migrate_private_key(&agent).unwrap());
assert!(!agent.join("identity.key").exists(), "key left in agents/");
assert!(mur.join("keys/pm/identity.key").exists());
assert_eq!(
AgentIdentity::load(&agent).unwrap().pubkey_text(),
id.pubkey_text(),
"the same identity must load after the move"
);
}
#[test]
fn migration_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let agent = tmp.path().join("agents").join("pm");
AgentIdentity::generate().save(&agent).unwrap(); assert!(!migrate_private_key(&agent).unwrap());
assert!(!migrate_private_key(&agent).unwrap());
}
#[test]
fn migration_refuses_when_the_destination_differs() {
let tmp = tempfile::tempdir().unwrap();
let mur = tmp.path();
let agent = mur.join("agents").join("pm");
let migrated = AgentIdentity::generate();
migrated.save(&agent).unwrap();
let stray = AgentIdentity::generate();
std::fs::write(agent.join("identity.key"), stray.signing.to_bytes()).unwrap();
let err = migrate_private_key(&agent).unwrap_err();
assert!(matches!(err, IdentityError::Exists(_)), "got {err:?}");
assert_eq!(
std::fs::read(mur.join("keys/pm/identity.key")).unwrap(),
migrated.signing.to_bytes().to_vec(),
"the destination key was modified despite the refusal"
);
assert!(
agent.join("identity.key").exists(),
"the source was removed despite the refusal"
);
}
#[test]
fn migration_clears_an_identical_leftover() {
let tmp = tempfile::tempdir().unwrap();
let agent = tmp.path().join("agents").join("pm");
let id = AgentIdentity::generate();
id.save(&agent).unwrap();
std::fs::write(agent.join("identity.key"), id.signing.to_bytes()).unwrap();
assert!(!migrate_private_key(&agent).unwrap());
assert!(!agent.join("identity.key").exists());
}
#[test]
fn migration_skips_non_agent_identities() {
let tmp = tempfile::tempdir().unwrap();
let mur = tmp.path();
for dir in [
mur.to_path_buf(),
mur.join("commander"),
mur.join("publisher"),
] {
AgentIdentity::generate().save(&dir).unwrap();
assert!(!migrate_private_key(&dir).unwrap());
assert!(dir.join("identity.key").exists(), "{}", dir.display());
}
}
#[test]
fn save_refuses_to_overwrite_an_existing_key() {
let dir = tempfile::tempdir().unwrap();
let first = AgentIdentity::generate();
first.save(dir.path()).unwrap();
let original = std::fs::read(dir.path().join("identity.key")).unwrap();
let err = AgentIdentity::generate().save(dir.path()).unwrap_err();
assert!(
matches!(err, IdentityError::Exists(_)),
"expected Exists, got {err:?}"
);
assert_eq!(
std::fs::read(dir.path().join("identity.key")).unwrap(),
original,
"the existing key was modified despite the refusal"
);
}
#[test]
fn save_into_an_empty_directory_succeeds() {
let dir = tempfile::tempdir().unwrap();
let id = AgentIdentity::generate();
id.save(dir.path()).unwrap();
assert_eq!(
AgentIdentity::load(dir.path()).unwrap().pubkey_text(),
id.pubkey_text()
);
}
#[test]
fn a_missing_key_is_still_notfound() {
let dir = tempfile::tempdir().unwrap();
assert!(matches!(
AgentIdentity::load(dir.path()).unwrap_err(),
IdentityError::NotFound
));
}
}
#[cfg(test)]
mod identity_x25519_tests {
use super::*;
#[test]
fn x25519_pub_matches_secret_derivation() {
let id = AgentIdentity::generate();
let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
assert_eq!(from_secret.as_bytes(), &from_pub);
}
}