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 load_pubkey(dir: &Path) -> Result<[u8; 32], IdentityError> {
103 let path = dir.join("identity.pub");
104 if !path.exists() {
105 return Err(IdentityError::NotFound);
106 }
107 decode_pubkey(fs::read_to_string(&path)?.trim())
108 }
109
110 pub fn signing_key(&self) -> &SigningKey {
111 &self.signing
112 }
113
114 pub fn sign_bytes(&self, msg: &[u8]) -> [u8; 64] {
119 use ed25519_dalek::Signer;
120 self.signing.sign(msg).to_bytes()
121 }
122
123 pub fn sign_multibase(&self, msg: &[u8]) -> String {
126 multibase::encode(multibase::Base::Base58Btc, self.sign_bytes(msg))
127 }
128
129 pub fn verifying_key(&self) -> VerifyingKey {
130 self.signing.verifying_key()
131 }
132
133 pub fn verifying_key_bytes(&self) -> [u8; 32] {
134 *self.signing.verifying_key().as_bytes()
135 }
136
137 pub fn pubkey_text(&self) -> String {
138 encode_pubkey(&self.signing.verifying_key())
139 }
140
141 pub fn public_key_multibase(&self) -> String {
145 encode_pubkey(&self.signing.verifying_key())
146 }
147
148 pub fn to_x25519_static_secret(&self) -> x25519_dalek::StaticSecret {
154 let scalar_bytes = self.signing.to_scalar_bytes();
155 x25519_dalek::StaticSecret::from(scalar_bytes)
156 }
157}
158
159pub fn verify_bytes(pubkey: &[u8; 32], msg: &[u8], sig_multibase: &str) -> bool {
162 let Ok((_, sig_bytes)) = multibase::decode(sig_multibase) else {
163 return false;
164 };
165 let Ok(sig_arr): Result<[u8; 64], _> = sig_bytes.try_into() else {
166 return false;
167 };
168 let Ok(vk) = ed25519_dalek::VerifyingKey::from_bytes(pubkey) else {
169 return false;
170 };
171 vk.verify_strict(msg, &ed25519_dalek::Signature::from_bytes(&sig_arr))
172 .is_ok()
173}
174
175pub fn valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
177 VerifyingKey::from_bytes(bytes).is_ok()
178}
179
180pub fn encode_pubkey(key: &VerifyingKey) -> String {
182 multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
183}
184
185pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
187 let (_base, bytes) = multibase::decode(text)?;
188 if bytes.len() != 32 {
189 return Err(IdentityError::InvalidKey(format!(
190 "pubkey must be 32 bytes, got {}",
191 bytes.len()
192 )));
193 }
194 let mut out = [0u8; 32];
195 out.copy_from_slice(&bytes);
196 Ok(out)
197}
198
199pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
207 let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
208 let point = compressed.decompress()?;
209 Some(point.to_montgomery().to_bytes())
210}
211
212pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
214 let ed = decode_pubkey(text)?;
215 ed25519_pub_to_x25519(&ed)
216 .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
217}
218
219pub fn default_dir(agent_home: &Path) -> PathBuf {
221 agent_home.to_path_buf()
222}
223
224use serde::{Deserialize, Serialize};
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum RotationReason {
236 Scheduled,
237 SuspectCompromise,
238 OwnerChange,
239 Emergency,
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
250pub struct RotationAttestation {
251 pub schema: u32,
253 pub uuid: String,
255 pub algorithm: String,
257 pub old_pubkey: String,
259 pub new_pubkey: String,
261 pub old_key_version: u32,
262 pub new_key_version: u32,
264 pub rotated_at: String,
266 pub reason: RotationReason,
267 #[serde(default, skip_serializing_if = "String::is_empty")]
270 pub signature: String,
271 #[serde(default, skip_serializing_if = "is_false")]
273 pub bootstrap: bool,
274}
275
276fn is_false(b: &bool) -> bool {
277 !*b
278}
279
280impl RotationAttestation {
281 pub fn new(
283 uuid: impl Into<String>,
284 old_pubkey: impl Into<String>,
285 new_pubkey: impl Into<String>,
286 old_key_version: u32,
287 new_key_version: u32,
288 rotated_at: impl Into<String>,
289 reason: RotationReason,
290 ) -> Self {
291 Self {
292 schema: 1,
293 uuid: uuid.into(),
294 algorithm: "ed25519".into(),
295 old_pubkey: old_pubkey.into(),
296 new_pubkey: new_pubkey.into(),
297 old_key_version,
298 new_key_version,
299 rotated_at: rotated_at.into(),
300 reason,
301 signature: String::new(),
302 bootstrap: false,
303 }
304 }
305
306 pub fn into_bootstrap(mut self) -> Self {
310 self.bootstrap = true;
311 self.old_pubkey = String::new();
312 self.signature = String::new();
313 self
314 }
315
316 pub fn canonical_bytes(&self) -> Vec<u8> {
320 let mut clone = self.clone();
321 clone.signature = String::new();
322 canonical_json(&clone)
323 }
324
325 pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
328 use ed25519_dalek::Signer;
329 let sig = signing.sign(&self.canonical_bytes());
330 self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
331 }
332
333 pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
342 if self.bootstrap {
343 return Ok(());
344 }
345 if self.signature.is_empty() {
346 return Err(IdentityError::InvalidKey(
347 "attestation signature is empty".into(),
348 ));
349 }
350 let pub_bytes = decode_pubkey(old_pubkey)?;
351 let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
352 .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
353 let (_base, sig_bytes) = multibase::decode(&self.signature)?;
354 let sig_arr: [u8; 64] = sig_bytes
355 .as_slice()
356 .try_into()
357 .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
358 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
359 verifying
360 .verify_strict(&self.canonical_bytes(), &sig)
361 .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
362 Ok(())
363 }
364
365 pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
368 if self.reason == RotationReason::Emergency && self.signature.is_empty() {
369 return Ok(());
370 }
371 self.verify(old_pubkey)
372 }
373}
374
375#[derive(Debug, Clone, Copy, Default)]
381pub struct ChainOptions {
382 pub allow_emergency: bool,
387}
388
389#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct ChainOutcome {
392 pub head_key_version: u32,
394 pub head_pubkey: String,
396 pub length: usize,
398}
399
400#[derive(Debug)]
402pub enum ChainError {
403 MissingBootstrap,
405 VersionSkip { expected: u32, got: u32 },
407 PubkeyDiscontinuity { at_version: u32 },
409 DuplicateVersion(u32),
411 BadSignature { at_version: u32, detail: String },
413 EmergencyDisallowed { at_version: u32 },
415}
416
417impl std::fmt::Display for ChainError {
418 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
419 match self {
420 Self::MissingBootstrap => {
421 write!(
422 f,
423 "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
424 )
425 }
426 Self::VersionSkip { expected, got } => {
427 write!(f, "version skip: expected {expected}, got {got}")
428 }
429 Self::PubkeyDiscontinuity { at_version } => {
430 write!(
431 f,
432 "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
433 )
434 }
435 Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
436 Self::BadSignature { at_version, detail } => {
437 write!(f, "bad signature at key_version {at_version}: {detail}")
438 }
439 Self::EmergencyDisallowed { at_version } => {
440 write!(
441 f,
442 "emergency attestation at key_version {at_version} requires allow_emergency=true"
443 )
444 }
445 }
446 }
447}
448
449impl std::error::Error for ChainError {}
450
451pub fn verify_chain(
454 chain: &[RotationAttestation],
455 opts: ChainOptions,
456) -> std::result::Result<ChainOutcome, ChainError> {
457 if chain.is_empty() {
458 return Err(ChainError::MissingBootstrap);
459 }
460 let first = &chain[0];
461 if !first.bootstrap || first.new_key_version != 0 {
462 return Err(ChainError::MissingBootstrap);
463 }
464
465 let mut prev_pubkey = first.new_pubkey.clone();
466 let mut prev_version = 0u32;
467 let mut seen_versions = std::collections::HashSet::new();
468 seen_versions.insert(0u32);
469
470 for (i, a) in chain.iter().enumerate().skip(1) {
471 if !seen_versions.insert(a.new_key_version) {
473 return Err(ChainError::DuplicateVersion(a.new_key_version));
474 }
475 let expected = prev_version + 1;
477 if a.old_key_version != prev_version || a.new_key_version != expected {
478 return Err(ChainError::VersionSkip {
479 expected,
480 got: a.new_key_version,
481 });
482 }
483 if a.old_pubkey != prev_pubkey {
485 return Err(ChainError::PubkeyDiscontinuity {
486 at_version: a.new_key_version,
487 });
488 }
489 if a.reason == RotationReason::Emergency {
491 if !opts.allow_emergency {
492 return Err(ChainError::EmergencyDisallowed {
493 at_version: a.new_key_version,
494 });
495 }
496 if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
498 return Err(ChainError::BadSignature {
499 at_version: a.new_key_version,
500 detail: e.to_string(),
501 });
502 }
503 } else if let Err(e) = a.verify(&a.old_pubkey) {
504 return Err(ChainError::BadSignature {
505 at_version: a.new_key_version,
506 detail: e.to_string(),
507 });
508 }
509
510 prev_pubkey = a.new_pubkey.clone();
511 prev_version = a.new_key_version;
512 let _ = i; }
514
515 Ok(ChainOutcome {
516 head_key_version: prev_version,
517 head_pubkey: prev_pubkey,
518 length: chain.len(),
519 })
520}
521
522fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
526 let v: serde_json::Value =
529 serde_json::to_value(value).expect("serialize should not fail for our types");
530 let mut out = Vec::new();
531 write_canonical(&mut out, &v);
532 out
533}
534
535fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
536 use serde_json::Value;
537 match v {
538 Value::Null => out.extend_from_slice(b"null"),
539 Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
540 Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
541 Value::String(s) => {
542 let escaped = serde_json::to_string(s).unwrap();
544 out.extend_from_slice(escaped.as_bytes());
545 }
546 Value::Array(arr) => {
547 out.push(b'[');
548 for (i, item) in arr.iter().enumerate() {
549 if i > 0 {
550 out.push(b',');
551 }
552 write_canonical(out, item);
553 }
554 out.push(b']');
555 }
556 Value::Object(map) => {
557 let mut keys: Vec<&String> = map.keys().collect();
559 keys.sort();
560 out.push(b'{');
561 for (i, k) in keys.iter().enumerate() {
562 if i > 0 {
563 out.push(b',');
564 }
565 let kesc = serde_json::to_string(k).unwrap();
566 out.extend_from_slice(kesc.as_bytes());
567 out.push(b':');
568 write_canonical(out, &map[*k]);
569 }
570 out.push(b'}');
571 }
572 }
573}
574
575#[cfg(test)]
576mod identity_x25519_tests {
577 use super::*;
578
579 #[test]
580 fn x25519_pub_matches_secret_derivation() {
581 let id = AgentIdentity::generate();
585 let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
586 let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
587 assert_eq!(from_secret.as_bytes(), &from_pub);
588 }
589}