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 valid_ed25519_pubkey(bytes: &[u8; 32]) -> bool {
161 VerifyingKey::from_bytes(bytes).is_ok()
162}
163
164pub fn encode_pubkey(key: &VerifyingKey) -> String {
166 multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
167}
168
169pub fn decode_pubkey(text: &str) -> Result<[u8; 32], IdentityError> {
171 let (_base, bytes) = multibase::decode(text)?;
172 if bytes.len() != 32 {
173 return Err(IdentityError::InvalidKey(format!(
174 "pubkey must be 32 bytes, got {}",
175 bytes.len()
176 )));
177 }
178 let mut out = [0u8; 32];
179 out.copy_from_slice(&bytes);
180 Ok(out)
181}
182
183pub fn ed25519_pub_to_x25519(ed_pub: &[u8; 32]) -> Option<[u8; 32]> {
191 let compressed = curve25519_dalek::edwards::CompressedEdwardsY(*ed_pub);
192 let point = compressed.decompress()?;
193 Some(point.to_montgomery().to_bytes())
194}
195
196pub fn x25519_pub_from_multibase(text: &str) -> Result<[u8; 32], IdentityError> {
198 let ed = decode_pubkey(text)?;
199 ed25519_pub_to_x25519(&ed)
200 .ok_or_else(|| IdentityError::InvalidKey("pubkey is not a valid Edwards point".into()))
201}
202
203pub fn default_dir(agent_home: &Path) -> PathBuf {
205 agent_home.to_path_buf()
206}
207
208use serde::{Deserialize, Serialize};
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(rename_all = "snake_case")]
219pub enum RotationReason {
220 Scheduled,
221 SuspectCompromise,
222 OwnerChange,
223 Emergency,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234pub struct RotationAttestation {
235 pub schema: u32,
237 pub uuid: String,
239 pub algorithm: String,
241 pub old_pubkey: String,
243 pub new_pubkey: String,
245 pub old_key_version: u32,
246 pub new_key_version: u32,
248 pub rotated_at: String,
250 pub reason: RotationReason,
251 #[serde(default, skip_serializing_if = "String::is_empty")]
254 pub signature: String,
255 #[serde(default, skip_serializing_if = "is_false")]
257 pub bootstrap: bool,
258}
259
260fn is_false(b: &bool) -> bool {
261 !*b
262}
263
264impl RotationAttestation {
265 pub fn new(
267 uuid: impl Into<String>,
268 old_pubkey: impl Into<String>,
269 new_pubkey: impl Into<String>,
270 old_key_version: u32,
271 new_key_version: u32,
272 rotated_at: impl Into<String>,
273 reason: RotationReason,
274 ) -> Self {
275 Self {
276 schema: 1,
277 uuid: uuid.into(),
278 algorithm: "ed25519".into(),
279 old_pubkey: old_pubkey.into(),
280 new_pubkey: new_pubkey.into(),
281 old_key_version,
282 new_key_version,
283 rotated_at: rotated_at.into(),
284 reason,
285 signature: String::new(),
286 bootstrap: false,
287 }
288 }
289
290 pub fn into_bootstrap(mut self) -> Self {
294 self.bootstrap = true;
295 self.old_pubkey = String::new();
296 self.signature = String::new();
297 self
298 }
299
300 pub fn canonical_bytes(&self) -> Vec<u8> {
304 let mut clone = self.clone();
305 clone.signature = String::new();
306 canonical_json(&clone)
307 }
308
309 pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
312 use ed25519_dalek::Signer;
313 let sig = signing.sign(&self.canonical_bytes());
314 self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
315 }
316
317 pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
326 if self.bootstrap {
327 return Ok(());
328 }
329 if self.signature.is_empty() {
330 return Err(IdentityError::InvalidKey(
331 "attestation signature is empty".into(),
332 ));
333 }
334 let pub_bytes = decode_pubkey(old_pubkey)?;
335 let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
336 .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
337 let (_base, sig_bytes) = multibase::decode(&self.signature)?;
338 let sig_arr: [u8; 64] = sig_bytes
339 .as_slice()
340 .try_into()
341 .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
342 let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
343 verifying
344 .verify_strict(&self.canonical_bytes(), &sig)
345 .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
346 Ok(())
347 }
348
349 pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
352 if self.reason == RotationReason::Emergency && self.signature.is_empty() {
353 return Ok(());
354 }
355 self.verify(old_pubkey)
356 }
357}
358
359#[derive(Debug, Clone, Copy, Default)]
365pub struct ChainOptions {
366 pub allow_emergency: bool,
371}
372
373#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct ChainOutcome {
376 pub head_key_version: u32,
378 pub head_pubkey: String,
380 pub length: usize,
382}
383
384#[derive(Debug)]
386pub enum ChainError {
387 MissingBootstrap,
389 VersionSkip { expected: u32, got: u32 },
391 PubkeyDiscontinuity { at_version: u32 },
393 DuplicateVersion(u32),
395 BadSignature { at_version: u32, detail: String },
397 EmergencyDisallowed { at_version: u32 },
399}
400
401impl std::fmt::Display for ChainError {
402 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403 match self {
404 Self::MissingBootstrap => {
405 write!(
406 f,
407 "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
408 )
409 }
410 Self::VersionSkip { expected, got } => {
411 write!(f, "version skip: expected {expected}, got {got}")
412 }
413 Self::PubkeyDiscontinuity { at_version } => {
414 write!(
415 f,
416 "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
417 )
418 }
419 Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
420 Self::BadSignature { at_version, detail } => {
421 write!(f, "bad signature at key_version {at_version}: {detail}")
422 }
423 Self::EmergencyDisallowed { at_version } => {
424 write!(
425 f,
426 "emergency attestation at key_version {at_version} requires allow_emergency=true"
427 )
428 }
429 }
430 }
431}
432
433impl std::error::Error for ChainError {}
434
435pub fn verify_chain(
438 chain: &[RotationAttestation],
439 opts: ChainOptions,
440) -> std::result::Result<ChainOutcome, ChainError> {
441 if chain.is_empty() {
442 return Err(ChainError::MissingBootstrap);
443 }
444 let first = &chain[0];
445 if !first.bootstrap || first.new_key_version != 0 {
446 return Err(ChainError::MissingBootstrap);
447 }
448
449 let mut prev_pubkey = first.new_pubkey.clone();
450 let mut prev_version = 0u32;
451 let mut seen_versions = std::collections::HashSet::new();
452 seen_versions.insert(0u32);
453
454 for (i, a) in chain.iter().enumerate().skip(1) {
455 if !seen_versions.insert(a.new_key_version) {
457 return Err(ChainError::DuplicateVersion(a.new_key_version));
458 }
459 let expected = prev_version + 1;
461 if a.old_key_version != prev_version || a.new_key_version != expected {
462 return Err(ChainError::VersionSkip {
463 expected,
464 got: a.new_key_version,
465 });
466 }
467 if a.old_pubkey != prev_pubkey {
469 return Err(ChainError::PubkeyDiscontinuity {
470 at_version: a.new_key_version,
471 });
472 }
473 if a.reason == RotationReason::Emergency {
475 if !opts.allow_emergency {
476 return Err(ChainError::EmergencyDisallowed {
477 at_version: a.new_key_version,
478 });
479 }
480 if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
482 return Err(ChainError::BadSignature {
483 at_version: a.new_key_version,
484 detail: e.to_string(),
485 });
486 }
487 } else if let Err(e) = a.verify(&a.old_pubkey) {
488 return Err(ChainError::BadSignature {
489 at_version: a.new_key_version,
490 detail: e.to_string(),
491 });
492 }
493
494 prev_pubkey = a.new_pubkey.clone();
495 prev_version = a.new_key_version;
496 let _ = i; }
498
499 Ok(ChainOutcome {
500 head_key_version: prev_version,
501 head_pubkey: prev_pubkey,
502 length: chain.len(),
503 })
504}
505
506fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
510 let v: serde_json::Value =
513 serde_json::to_value(value).expect("serialize should not fail for our types");
514 let mut out = Vec::new();
515 write_canonical(&mut out, &v);
516 out
517}
518
519fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
520 use serde_json::Value;
521 match v {
522 Value::Null => out.extend_from_slice(b"null"),
523 Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
524 Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
525 Value::String(s) => {
526 let escaped = serde_json::to_string(s).unwrap();
528 out.extend_from_slice(escaped.as_bytes());
529 }
530 Value::Array(arr) => {
531 out.push(b'[');
532 for (i, item) in arr.iter().enumerate() {
533 if i > 0 {
534 out.push(b',');
535 }
536 write_canonical(out, item);
537 }
538 out.push(b']');
539 }
540 Value::Object(map) => {
541 let mut keys: Vec<&String> = map.keys().collect();
543 keys.sort();
544 out.push(b'{');
545 for (i, k) in keys.iter().enumerate() {
546 if i > 0 {
547 out.push(b',');
548 }
549 let kesc = serde_json::to_string(k).unwrap();
550 out.extend_from_slice(kesc.as_bytes());
551 out.push(b':');
552 write_canonical(out, &map[*k]);
553 }
554 out.push(b'}');
555 }
556 }
557}
558
559#[cfg(test)]
560mod identity_x25519_tests {
561 use super::*;
562
563 #[test]
564 fn x25519_pub_matches_secret_derivation() {
565 let id = AgentIdentity::generate();
569 let from_secret = x25519_dalek::PublicKey::from(&id.to_x25519_static_secret());
570 let from_pub = x25519_pub_from_multibase(&id.public_key_multibase()).unwrap();
571 assert_eq!(from_secret.as_bytes(), &from_pub);
572 }
573}