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 verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
146 let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
147 return false;
148 };
149 let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
150 return false;
151 };
152 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
153 return false;
154 };
155 vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
156 .is_ok()
157}
158
159pub fn encode_pubkey(key: &VerifyingKey) -> String {
161 multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
162}
163
164pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
166 let (_base, bytes) = multibase::decode(text)?;
167 if bytes.len() != 32 {
168 return Err(IdentityError::InvalidKey(format!(
169 "pubkey must be 32 bytes, got {}",
170 bytes.len()
171 )));
172 }
173 let mut out = [0u8; 32];
174 out.copy_from_slice(&bytes);
175 Ok(out)
176}
177
178pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
186 let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
187 let point = compressed.decompress()?;
188 Some(point.to_montgomery().to_bytes())
189}
190
191pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
193 let ed = decode_pubkey(text)?;
194 ed25519_pub_to_x25519(&ed)
195 .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
196}
197
198pub fn default_dir(agent_home: &Path) -> PathBuf {
200 agent_home.to_path_buf()
201}
202
203use serde::{Deserialize, Serialize};
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum RotationReason {
215 Scheduled,
216 SuspectCompromise,
217 OwnerChange,
218 Emergency,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct RotationAttestation {
230 pub schema: u32,
232 pub uuid: String,
234 pub algorithm: String,
236 pub old_pubkey: String,
238 pub new_pubkey: String,
240 pub old_key_version: u32,
241 pub new_key_version: u32,
243 pub rotated_at: String,
245 pub reason: RotationReason,
246 #[serde(default, skip_serializing_if = "String::is_empty")]
249 pub signature: String,
250 #[serde(default, skip_serializing_if = "is_false")]
252 pub bootstrap: bool,
253}
254
255fn is_false(b: &bool) -> bool {
256 !*b
257}
258
259impl RotationAttestation {
260 pub fn new(
262 uuid: impl Into<String>,
263 old_pubkey: impl Into<String>,
264 new_pubkey: impl Into<String>,
265 old_key_version: u32,
266 new_key_version: u32,
267 rotated_at: impl Into<String>,
268 reason: RotationReason,
269 ) -> Self {
270 Self {
271 schema: 1,
272 uuid: uuid.into(),
273 algorithm: "ed25519".into(),
274 old_pubkey: old_pubkey.into(),
275 new_pubkey: new_pubkey.into(),
276 old_key_version,
277 new_key_version,
278 rotated_at: rotated_at.into(),
279 reason,
280 signature: String::new(),
281 bootstrap: false,
282 }
283 }
284
285 pub fn into_bootstrap(mut self) -> Self {
289 self.bootstrap = true;
290 self.old_pubkey = String::new();
291 self.signature = String::new();
292 self
293 }
294
295 pub fn canonical_bytes(&self) -> Vec<u8> {
299 let mut clone = self.clone();
300 clone.signature = String::new();
301 canonical_json(&clone)
302 }
303
304 pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
307 use ed25519_dalek::Signer;
308 let sig = signing.sign(&self.canonical_bytes());
309 self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
310 }
311
312 pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
321 if self.bootstrap {
322 return Ok(());
323 }
324 if self.signature.is_empty() {
325 return Err(IdentityError::InvalidKey(
326 "attestation signature is empty".into(),
327 ));
328 }
329 let pub_bytes = decode_pubkey(old_pubkey)?;
330 let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
331 .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
332 let (_base, sig_bytes) = multibase::decode(&self.signature)?;
333 let sig_arr: [u8; 64] = sig_bytes
334 .as_slice()
335 .try_into()
336 .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
337 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
338 verifying
339 .verify_strict(&self.canonical_bytes(), &sig)
340 .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
341 Ok(())
342 }
343
344 pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
347 if self.reason == RotationReason::Emergency && self.signature.is_empty() {
348 return Ok(());
349 }
350 self.verify(old_pubkey)
351 }
352}
353
354#[derive(Debug, Clone, Copy, Default)]
360pub struct ChainOptions {
361 pub allow_emergency: bool,
366}
367
368#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct ChainOutcome {
371 pub head_key_version: u32,
373 pub head_pubkey: String,
375 pub length: usize,
377}
378
379#[derive(Debug)]
381pub enum ChainError {
382 MissingBootstrap,
384 VersionSkip { expected: u32, got: u32 },
386 PubkeyDiscontinuity { at_version: u32 },
388 DuplicateVersion(u32),
390 BadSignature { at_version: u32, detail: String },
392 EmergencyDisallowed { at_version: u32 },
394}
395
396impl std::fmt::Display for ChainError {
397 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
398 match self {
399 Self::MissingBootstrap => {
400 write!(
401 f,
402 "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
403 )
404 }
405 Self::VersionSkip { expected, got } => {
406 write!(f, "version skip: expected {expected}, got {got}")
407 }
408 Self::PubkeyDiscontinuity { at_version } => {
409 write!(
410 f,
411 "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
412 )
413 }
414 Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
415 Self::BadSignature { at_version, detail } => {
416 write!(f, "bad signature at key_version {at_version}: {detail}")
417 }
418 Self::EmergencyDisallowed { at_version } => {
419 write!(
420 f,
421 "emergency attestation at key_version {at_version} requires allow_emergency=true"
422 )
423 }
424 }
425 }
426}
427
428impl std::error::Error for ChainError {}
429
430pub fn verify_chain(
433 chain: &[RotationAttestation],
434 opts: ChainOptions,
435) -> std::result::Result<ChainOutcome, ChainError> {
436 if chain.is_empty() {
437 return Err(ChainError::MissingBootstrap);
438 }
439 let first = &chain[0];
440 if !first.bootstrap || first.new_key_version != 0 {
441 return Err(ChainError::MissingBootstrap);
442 }
443
444 let mut prev_pubkey = first.new_pubkey.clone();
445 let mut prev_version = 0u32;
446 let mut seen_versions = std::collections::HashSet::new();
447 seen_versions.insert(0u32);
448
449 for (i, a) in chain.iter().enumerate().skip(1) {
450 if !seen_versions.insert(a.new_key_version) {
452 return Err(ChainError::DuplicateVersion(a.new_key_version));
453 }
454 let expected = prev_version + 1;
456 if a.old_key_version != prev_version || a.new_key_version != expected {
457 return Err(ChainError::VersionSkip {
458 expected,
459 got: a.new_key_version,
460 });
461 }
462 if a.old_pubkey != prev_pubkey {
464 return Err(ChainError::PubkeyDiscontinuity {
465 at_version: a.new_key_version,
466 });
467 }
468 if a.reason == RotationReason::Emergency {
470 if !opts.allow_emergency {
471 return Err(ChainError::EmergencyDisallowed {
472 at_version: a.new_key_version,
473 });
474 }
475 if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
477 return Err(ChainError::BadSignature {
478 at_version: a.new_key_version,
479 detail: e.to_string(),
480 });
481 }
482 } else if let Err(e) = a.verify(&a.old_pubkey) {
483 return Err(ChainError::BadSignature {
484 at_version: a.new_key_version,
485 detail: e.to_string(),
486 });
487 }
488
489 prev_pubkey = a.new_pubkey.clone();
490 prev_version = a.new_key_version;
491 let _ = i; }
493
494 Ok(ChainOutcome {
495 head_key_version: prev_version,
496 head_pubkey: prev_pubkey,
497 length: chain.len(),
498 })
499}
500
501fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
505 let v: serde_json::Value =
508 serde_json::to_value(value).expect("serialize should not fail for our types");
509 let mut out = Vec::new();
510 write_canonical(&mut out, &v);
511 out
512}
513
514fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
515 use serde_json::Value;
516 match v {
517 Value::Null => out.extend_from_slice(b"null"),
518 Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
519 Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
520 Value::String(s) => {
521 let escaped = serde_json::to_string(s).unwrap();
523 out.extend_from_slice(escaped.as_bytes());
524 }
525 Value::Array(arr) => {
526 out.push(b'[');
527 for (i, item) in arr.iter().enumerate() {
528 if i > 0 {
529 out.push(b',');
530 }
531 write_canonical(out, item);
532 }
533 out.push(b']');
534 }
535 Value::Object(map) => {
536 let mut keys: Vec<&String> = map.keys().collect();
538 keys.sort();
539 out.push(b'{');
540 for (i, k) in keys.iter().enumerate() {
541 if i > 0 {
542 out.push(b',');
543 }
544 let kesc = serde_json::to_string(k).unwrap();
545 out.extend_from_slice(kesc.as_bytes());
546 out.push(b':');
547 write_canonical(out, &map[*k]);
548 }
549 out.push(b'}');
550 }
551 }
552}
553
554#[cfg(test)]
555mod identity_x25519_tests {
556 use super::*;
557
558 #[test]
559 fn x25519_pub_matches_secret_derivation() {
560 let id = AgentIdentity::generate();
564 let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
565 let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
566 assert_eq!(from_secret.as_bytes(), &from_pub);
567 }
568}