1use ed25519_dalek::{SECRET_KEY_LENGTH, SigningKey, VerifyingKey};
7use rand_core::OsRng;
8use std::fs;
9use std::io;
10use std::path::{Path, PathBuf};
11
12#[cfg(unix)]
13use std::os::unix::fs::PermissionsExt;
14
15#[derive(Debug, thiserror::Error)]
16pub enum IdentityError {
17 #[error("identity files not found")]
18 NotFound,
19 #[error("io error: {0}")]
20 Io(#[from] io::Error),
21 #[error("invalid key material: {0}")]
22 InvalidKey(String),
23 #[error("multibase decode error: {0}")]
24 Multibase(#[from] multibase::Error),
25}
26
27#[derive(Clone)]
28pub struct AgentIdentity {
29 signing: SigningKey,
30}
31
32impl std::fmt::Debug for AgentIdentity {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 f.debug_struct("AgentIdentity")
35 .field("verifying_key", &self.signing.verifying_key())
36 .finish()
37 }
38}
39
40impl AgentIdentity {
41 pub fn generate() -> Self {
43 Self {
44 signing: SigningKey::generate(&mut OsRng),
45 }
46 }
47
48 pub fn save(&self, dir: &Path) -> Result<(), IdentityError> {
51 fs::create_dir_all(dir)?;
52 let priv_path = dir.join("identity.key");
53 let pub_path = dir.join("identity.pub");
54
55 fs::write(&priv_path, self.signing.to_bytes())?;
56 #[cfg(unix)]
57 {
58 let mut perms = fs::metadata(&priv_path)?.permissions();
59 perms.set_mode(0o600);
60 fs::set_permissions(&priv_path, perms)?;
61 }
62
63 let pub_text = encode_pubkey(&self.signing.verifying_key());
64 fs::write(&pub_path, pub_text)?;
65 Ok(())
66 }
67
68 pub fn load(dir: &Path) -> Result<Self, IdentityError> {
72 let priv_path = dir.join("identity.key");
73 if !priv_path.exists() {
74 return Err(IdentityError::NotFound);
75 }
76 let bytes = fs::read(&priv_path)?;
77 if bytes.len() != SECRET_KEY_LENGTH {
78 return Err(IdentityError::InvalidKey(format!(
79 "expected {SECRET_KEY_LENGTH} bytes, got {}",
80 bytes.len()
81 )));
82 }
83 let arr: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().unwrap();
84 let signing = SigningKey::from_bytes(&arr);
85
86 let pub_path = dir.join("identity.pub");
87 if pub_path.exists() {
88 let text = fs::read_to_string(&pub_path)?;
89 let loaded_pub = decode_pubkey(text.trim())?;
90 if loaded_pub != *signing.verifying_key().as_bytes() {
91 return Err(IdentityError::InvalidKey(
92 "identity.pub does not match identity.key".into(),
93 ));
94 }
95 }
96
97 Ok(Self { signing })
98 }
99
100 pub fn signing_key(&self) -> &SigningKey {
101 &self.signing
102 }
103
104 pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
109 use ed25519_dalek::Signer;
110 self.signing.sign(msg).to_bytes()
111 }
112
113 pub fn verifying_key(&self) -> VerifyingKey {
114 self.signing.verifying_key()
115 }
116
117 pub fn verifying_key_bytes(&self) -> [u8; 32] {
118 *self.signing.verifying_key().as_bytes()
119 }
120
121 pub fn pubkey_text(&self) -> String {
122 encode_pubkey(&self.signing.verifying_key())
123 }
124
125 pub fn public_key_multibase(&self) -> String {
129 encode_pubkey(&self.signing.verifying_key())
130 }
131
132 pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
138 let scalar_bytes = self.signing.to_scalar_bytes();
139 x25519_dalek::StaticSecret::from(scalar_bytes)
140 }
141}
142
143pub fn encode_pubkey(key: &VerifyingKey) -> String {
145 multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
146}
147
148pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
150 let (_base, bytes) = multibase::decode(text)?;
151 if bytes.len() != 32 {
152 return Err(IdentityError::InvalidKey(format!(
153 "pubkey must be 32 bytes, got {}",
154 bytes.len()
155 )));
156 }
157 let mut out = [0u8; 32];
158 out.copy_from_slice(&bytes);
159 Ok(out)
160}
161
162pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
170 let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
171 let point = compressed.decompress()?;
172 Some(point.to_montgomery().to_bytes())
173}
174
175pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
177 let ed = decode_pubkey(text)?;
178 ed25519_pub_to_x25519(&ed)
179 .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
180}
181
182pub fn default_dir(agent_home: &Path) -> PathBuf {
184 agent_home.to_path_buf()
185}
186
187use serde::{Deserialize, Serialize};
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum RotationReason {
199 Scheduled,
200 SuspectCompromise,
201 OwnerChange,
202 Emergency,
203}
204
205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
213pub struct RotationAttestation {
214 pub schema: u32,
216 pub uuid: String,
218 pub algorithm: String,
220 pub old_pubkey: String,
222 pub new_pubkey: String,
224 pub old_key_version: u32,
225 pub new_key_version: u32,
227 pub rotated_at: String,
229 pub reason: RotationReason,
230 #[serde(default, skip_serializing_if = "String::is_empty")]
233 pub signature: String,
234 #[serde(default, skip_serializing_if = "is_false")]
236 pub bootstrap: bool,
237}
238
239fn is_false(b: &bool) -> bool {
240 !*b
241}
242
243impl RotationAttestation {
244 pub fn new(
246 uuid: impl Into<String>,
247 old_pubkey: impl Into<String>,
248 new_pubkey: impl Into<String>,
249 old_key_version: u32,
250 new_key_version: u32,
251 rotated_at: impl Into<String>,
252 reason: RotationReason,
253 ) -> Self {
254 Self {
255 schema: 1,
256 uuid: uuid.into(),
257 algorithm: "ed25519".into(),
258 old_pubkey: old_pubkey.into(),
259 new_pubkey: new_pubkey.into(),
260 old_key_version,
261 new_key_version,
262 rotated_at: rotated_at.into(),
263 reason,
264 signature: String::new(),
265 bootstrap: false,
266 }
267 }
268
269 pub fn into_bootstrap(mut self) -> Self {
273 self.bootstrap = true;
274 self.old_pubkey = String::new();
275 self.signature = String::new();
276 self
277 }
278
279 pub fn canonical_bytes(&self) -> Vec<u8> {
283 let mut clone = self.clone();
284 clone.signature = String::new();
285 canonical_json(&clone)
286 }
287
288 pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
291 use ed25519_dalek::Signer;
292 let sig = signing.sign(&self.canonical_bytes());
293 self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
294 }
295
296 pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
305 if self.bootstrap {
306 return Ok(());
307 }
308 if self.signature.is_empty() {
309 return Err(IdentityError::InvalidKey(
310 "attestation signature is empty".into(),
311 ));
312 }
313 let pub_bytes = decode_pubkey(old_pubkey)?;
314 let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
315 .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
316 let (_base, sig_bytes) = multibase::decode(&self.signature)?;
317 let sig_arr: [u8; 64] = sig_bytes
318 .as_slice()
319 .try_into()
320 .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
321 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
322 verifying
323 .verify_strict(&self.canonical_bytes(), &sig)
324 .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
325 Ok(())
326 }
327
328 pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
331 if self.reason == RotationReason::Emergency && self.signature.is_empty() {
332 return Ok(());
333 }
334 self.verify(old_pubkey)
335 }
336}
337
338#[derive(Debug, Clone, Copy, Default)]
344pub struct ChainOptions {
345 pub allow_emergency: bool,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct ChainOutcome {
355 pub head_key_version: u32,
357 pub head_pubkey: String,
359 pub length: usize,
361}
362
363#[derive(Debug)]
365pub enum ChainError {
366 MissingBootstrap,
368 VersionSkip { expected: u32, got: u32 },
370 PubkeyDiscontinuity { at_version: u32 },
372 DuplicateVersion(u32),
374 BadSignature { at_version: u32, detail: String },
376 EmergencyDisallowed { at_version: u32 },
378}
379
380impl std::fmt::Display for ChainError {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 match self {
383 Self::MissingBootstrap => {
384 write!(
385 f,
386 "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
387 )
388 }
389 Self::VersionSkip { expected, got } => {
390 write!(f, "version skip: expected {expected}, got {got}")
391 }
392 Self::PubkeyDiscontinuity { at_version } => {
393 write!(
394 f,
395 "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
396 )
397 }
398 Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
399 Self::BadSignature { at_version, detail } => {
400 write!(f, "bad signature at key_version {at_version}: {detail}")
401 }
402 Self::EmergencyDisallowed { at_version } => {
403 write!(
404 f,
405 "emergency attestation at key_version {at_version} requires allow_emergency=true"
406 )
407 }
408 }
409 }
410}
411
412impl std::error::Error for ChainError {}
413
414pub fn verify_chain(
417 chain: &[RotationAttestation],
418 opts: ChainOptions,
419) -> std::result::Result<ChainOutcome, ChainError> {
420 if chain.is_empty() {
421 return Err(ChainError::MissingBootstrap);
422 }
423 let first = &chain[0];
424 if !first.bootstrap || first.new_key_version != 0 {
425 return Err(ChainError::MissingBootstrap);
426 }
427
428 let mut prev_pubkey = first.new_pubkey.clone();
429 let mut prev_version = 0u32;
430 let mut seen_versions = std::collections::HashSet::new();
431 seen_versions.insert(0u32);
432
433 for (i, a) in chain.iter().enumerate().skip(1) {
434 if !seen_versions.insert(a.new_key_version) {
436 return Err(ChainError::DuplicateVersion(a.new_key_version));
437 }
438 let expected = prev_version + 1;
440 if a.old_key_version != prev_version || a.new_key_version != expected {
441 return Err(ChainError::VersionSkip {
442 expected,
443 got: a.new_key_version,
444 });
445 }
446 if a.old_pubkey != prev_pubkey {
448 return Err(ChainError::PubkeyDiscontinuity {
449 at_version: a.new_key_version,
450 });
451 }
452 if a.reason == RotationReason::Emergency {
454 if !opts.allow_emergency {
455 return Err(ChainError::EmergencyDisallowed {
456 at_version: a.new_key_version,
457 });
458 }
459 if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
461 return Err(ChainError::BadSignature {
462 at_version: a.new_key_version,
463 detail: e.to_string(),
464 });
465 }
466 } else if let Err(e) = a.verify(&a.old_pubkey) {
467 return Err(ChainError::BadSignature {
468 at_version: a.new_key_version,
469 detail: e.to_string(),
470 });
471 }
472
473 prev_pubkey = a.new_pubkey.clone();
474 prev_version = a.new_key_version;
475 let _ = i; }
477
478 Ok(ChainOutcome {
479 head_key_version: prev_version,
480 head_pubkey: prev_pubkey,
481 length: chain.len(),
482 })
483}
484
485fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
489 let v: serde_json::Value =
492 serde_json::to_value(value).expect("serialize should not fail for our types");
493 let mut out = Vec::new();
494 write_canonical(&mut out, &v);
495 out
496}
497
498fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
499 use serde_json::Value;
500 match v {
501 Value::Null => out.extend_from_slice(b"null"),
502 Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
503 Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
504 Value::String(s) => {
505 let escaped = serde_json::to_string(s).unwrap();
507 out.extend_from_slice(escaped.as_bytes());
508 }
509 Value::Array(arr) => {
510 out.push(b'[');
511 for (i, item) in arr.iter().enumerate() {
512 if i > 0 {
513 out.push(b',');
514 }
515 write_canonical(out, item);
516 }
517 out.push(b']');
518 }
519 Value::Object(map) => {
520 let mut keys: Vec<&String> = map.keys().collect();
522 keys.sort();
523 out.push(b'{');
524 for (i, k) in keys.iter().enumerate() {
525 if i > 0 {
526 out.push(b',');
527 }
528 let kesc = serde_json::to_string(k).unwrap();
529 out.extend_from_slice(kesc.as_bytes());
530 out.push(b':');
531 write_canonical(out, &map[*k]);
532 }
533 out.push(b'}');
534 }
535 }
536}
537
538#[cfg(test)]
539mod identity_x25519_tests {
540 use super::*;
541
542 #[test]
543 fn x25519_pub_matches_secret_derivation() {
544 let id = AgentIdentity::generate();
548 let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
549 let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
550 assert_eq!(from_secret.as_bytes(), &from_pub);
551 }
552}