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("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()
}
}
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 priv_path = dir.join("identity.key");
let pub_path = dir.join("identity.pub");
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 = dir.join("identity.key");
if !priv_path.exists() {
return Err(IdentityError::NotFound);
}
let bytes = fs::read(&priv_path)?;
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 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 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 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_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);
}
}