Skip to main content

mur_common/
identity.rs

1//! Per-agent Ed25519 identity keypair.
2//!
3//! Loaded from `<agent_home>/identity.key` (private, 0600) and
4//! `<agent_home>/identity.pub` (public, multibase-encoded text).
5
6use 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    /// Generate a fresh Ed25519 keypair using OS CSPRNG.
42    pub fn generate() -> Self {
43        Self {
44            signing: SigningKey::generate(&mut OsRng),
45        }
46    }
47
48    /// Write both halves of the keypair to the given directory.
49    /// Private key is mode 0600 on Unix.
50    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    /// Load both halves from the given directory. Prefers the private key
69    /// (since we can derive pubkey from it); but also validates that a
70    /// present `identity.pub` matches.
71    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    /// Sign `msg` with the Ed25519 private key and return the raw 64-byte
105    /// signature. Callers that only have a `&AgentIdentity` (and therefore
106    /// cannot import `ed25519_dalek::Signer` themselves) should use this
107    /// instead of calling `signing_key().sign()` directly.
108    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    /// Alias for `pubkey_text()` — returns the verifying key as multibase
126    /// base58btc (`z`-prefixed string), matching the `bridge_pubkey_multibase`
127    /// field used in signed envelopes.
128    pub fn public_key_multibase(&self) -> String {
129        encode_pubkey(&self.signing.verifying_key())
130    }
131
132    /// Derive the X25519 static secret usable by Noise XK.
133    ///
134    /// Ed25519 and X25519 both use Curve25519 underneath; the Ed25519
135    /// SigningKey scalar maps directly to an X25519 StaticSecret.
136    /// ed25519-dalek 2.x exposes `to_scalar_bytes()` for exactly this.
137    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
143/// Encode an Ed25519 public key to multibase base58btc (`z` prefix).
144pub fn encode_pubkey(key: &VerifyingKey) -> String {
145    multibase::encode(multibase::Base::Base58Btc, key.as_bytes())
146}
147
148/// Decode a multibase-encoded pubkey. Accepts any multibase variant.
149pub 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
162/// Default location: `<agent_home>/identity.{key,pub}`.
163pub fn default_dir(agent_home: &Path) -> PathBuf {
164    agent_home.to_path_buf()
165}
166
167// ---------------------------------------------------------------------------
168// RotationAttestation — proof that a key rotation was authorized by the
169// holder of the prior identity key.
170// ---------------------------------------------------------------------------
171
172use serde::{Deserialize, Serialize};
173
174/// Why a rotation happened. Free-form audit hint; does not affect verification
175/// rules other than `Emergency`, which permits an empty signature.
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum RotationReason {
179    Scheduled,
180    SuspectCompromise,
181    OwnerChange,
182    Emergency,
183}
184
185/// Cryptographic proof of an identity-key rotation.
186///
187/// `signature` is multibase base58btc Ed25519 over `canonical_bytes()`
188/// (which serializes every field except `signature` itself).
189///
190/// For `reason = Emergency`, signature MAY be empty — those rotations
191/// require out-of-band admin approval to take effect.
192#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
193pub struct RotationAttestation {
194    /// Schema version. Always 1 for now.
195    pub schema: u32,
196    /// Agent UUIDv7 — stable across rotations.
197    pub uuid: String,
198    /// Signing algorithm. "ed25519" for now.
199    pub algorithm: String,
200    /// Outgoing pubkey (multibase). Empty string for the bootstrap entry only.
201    pub old_pubkey: String,
202    /// Incoming pubkey (multibase). Always present.
203    pub new_pubkey: String,
204    pub old_key_version: u32,
205    /// = old_key_version + 1 for non-emergency rotations.
206    pub new_key_version: u32,
207    /// RFC3339 timestamp.
208    pub rotated_at: String,
209    pub reason: RotationReason,
210    /// Multibase Ed25519 signature over canonical_bytes(). Empty for
211    /// Emergency reason or for the bootstrap entry.
212    #[serde(default, skip_serializing_if = "String::is_empty")]
213    pub signature: String,
214    /// True only for the create-time entry (no prior key existed).
215    #[serde(default, skip_serializing_if = "is_false")]
216    pub bootstrap: bool,
217}
218
219fn is_false(b: &bool) -> bool {
220    !*b
221}
222
223impl RotationAttestation {
224    /// Build a new (unsigned) attestation.
225    pub fn new(
226        uuid: impl Into<String>,
227        old_pubkey: impl Into<String>,
228        new_pubkey: impl Into<String>,
229        old_key_version: u32,
230        new_key_version: u32,
231        rotated_at: impl Into<String>,
232        reason: RotationReason,
233    ) -> Self {
234        Self {
235            schema: 1,
236            uuid: uuid.into(),
237            algorithm: "ed25519".into(),
238            old_pubkey: old_pubkey.into(),
239            new_pubkey: new_pubkey.into(),
240            old_key_version,
241            new_key_version,
242            rotated_at: rotated_at.into(),
243            reason,
244            signature: String::new(),
245            bootstrap: false,
246        }
247    }
248
249    /// Mark this attestation as the bootstrap entry written at agent
250    /// create time. Bootstrap entries have empty `old_pubkey` and empty
251    /// `signature`; they exist only to anchor the rotation chain.
252    pub fn into_bootstrap(mut self) -> Self {
253        self.bootstrap = true;
254        self.old_pubkey = String::new();
255        self.signature = String::new();
256        self
257    }
258
259    /// Canonical bytes used for signing. Serializes every field of `self`
260    /// EXCEPT `signature` (which is being computed) using JSON with sorted
261    /// keys and no whitespace.
262    pub fn canonical_bytes(&self) -> Vec<u8> {
263        let mut clone = self.clone();
264        clone.signature = String::new();
265        canonical_json(&clone)
266    }
267
268    /// Compute the Ed25519 signature using the given signing key and store
269    /// it in `self.signature`. Idempotent.
270    pub fn sign(&mut self, signing: &ed25519_dalek::SigningKey) {
271        use ed25519_dalek::Signer;
272        let sig = signing.sign(&self.canonical_bytes());
273        self.signature = multibase::encode(multibase::Base::Base58Btc, sig.to_bytes());
274    }
275
276    /// Verify `self.signature` against the supplied multibase-encoded
277    /// `old_pubkey`. Returns `Ok(())` on a valid signature.
278    ///
279    /// Bootstrap entries (`bootstrap = true`) are accepted unconditionally —
280    /// they have nothing to verify against.
281    /// Emergency entries (`reason = Emergency`) with empty signature are
282    /// REJECTED here; callers must use `verify_or_emergency` if they want
283    /// the emergency-allowed semantics.
284    pub fn verify(&self, old_pubkey: &str) -> Result<(), IdentityError> {
285        if self.bootstrap {
286            return Ok(());
287        }
288        if self.signature.is_empty() {
289            return Err(IdentityError::InvalidKey(
290                "attestation signature is empty".into(),
291            ));
292        }
293        let pub_bytes = decode_pubkey(old_pubkey)?;
294        let verifying = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
295            .map_err(|e| IdentityError::InvalidKey(format!("verifying key: {e}")))?;
296        let (_base, sig_bytes) = multibase::decode(&self.signature)?;
297        let sig_arr: [u8; 64] = sig_bytes
298            .as_slice()
299            .try_into()
300            .map_err(|_| IdentityError::InvalidKey("signature length != 64".into()))?;
301        let sig = ed25519_dalek::Signature::from_bytes(&sig_arr);
302        verifying
303            .verify_strict(&self.canonical_bytes(), &sig)
304            .map_err(|e| IdentityError::InvalidKey(format!("signature: {e}")))?;
305        Ok(())
306    }
307
308    /// Like `verify`, but accepts emergency rotations with empty signature.
309    /// Caller is responsible for the out-of-band approval check.
310    pub fn verify_or_emergency(&self, old_pubkey: &str) -> Result<(), IdentityError> {
311        if self.reason == RotationReason::Emergency && self.signature.is_empty() {
312            return Ok(());
313        }
314        self.verify(old_pubkey)
315    }
316}
317
318// ---------------------------------------------------------------------------
319// Chain verification — M5.1
320// ---------------------------------------------------------------------------
321
322/// Per-call options for `verify_chain`.
323#[derive(Debug, Clone, Copy, Default)]
324pub struct ChainOptions {
325    /// If true, accept emergency entries with empty signature (i.e. use
326    /// `verify_or_emergency` instead of strict `verify`). Commander code
327    /// that has out-of-band approval already should set this true; peer
328    /// code that is mirroring without approval should leave it false.
329    pub allow_emergency: bool,
330}
331
332/// Outcome of a successful chain verification.
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct ChainOutcome {
335    /// Highest key_version observed.
336    pub head_key_version: u32,
337    /// Pubkey at head_key_version.
338    pub head_pubkey: String,
339    /// Total entries (including bootstrap).
340    pub length: usize,
341}
342
343/// Errors from `verify_chain`.
344#[derive(Debug)]
345pub enum ChainError {
346    /// Chain is empty or first entry is not a bootstrap.
347    MissingBootstrap,
348    /// Chain skipped a key_version (e.g. went 1 -> 3).
349    VersionSkip { expected: u32, got: u32 },
350    /// `a[i].old_pubkey` does not match `a[i-1].new_pubkey`.
351    PubkeyDiscontinuity { at_version: u32 },
352    /// Same `new_key_version` appears twice in the chain.
353    DuplicateVersion(u32),
354    /// Bad Ed25519 signature on a non-bootstrap, non-emergency entry.
355    BadSignature { at_version: u32, detail: String },
356    /// Emergency entry encountered with `allow_emergency = false`.
357    EmergencyDisallowed { at_version: u32 },
358}
359
360impl std::fmt::Display for ChainError {
361    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
362        match self {
363            Self::MissingBootstrap => {
364                write!(
365                    f,
366                    "chain must start with a bootstrap entry (bootstrap=true, key_version=0)"
367                )
368            }
369            Self::VersionSkip { expected, got } => {
370                write!(f, "version skip: expected {expected}, got {got}")
371            }
372            Self::PubkeyDiscontinuity { at_version } => {
373                write!(
374                    f,
375                    "pubkey discontinuity at key_version {at_version}: old_pubkey does not match prior new_pubkey"
376                )
377            }
378            Self::DuplicateVersion(v) => write!(f, "duplicate key_version {v}"),
379            Self::BadSignature { at_version, detail } => {
380                write!(f, "bad signature at key_version {at_version}: {detail}")
381            }
382            Self::EmergencyDisallowed { at_version } => {
383                write!(
384                    f,
385                    "emergency attestation at key_version {at_version} requires allow_emergency=true"
386                )
387            }
388        }
389    }
390}
391
392impl std::error::Error for ChainError {}
393
394/// Walk the chain top-to-bottom and verify it forms a valid history.
395/// Returns the head pubkey + version on success.
396pub fn verify_chain(
397    chain: &[RotationAttestation],
398    opts: ChainOptions,
399) -> std::result::Result<ChainOutcome, ChainError> {
400    if chain.is_empty() {
401        return Err(ChainError::MissingBootstrap);
402    }
403    let first = &chain[0];
404    if !first.bootstrap || first.new_key_version != 0 {
405        return Err(ChainError::MissingBootstrap);
406    }
407
408    let mut prev_pubkey = first.new_pubkey.clone();
409    let mut prev_version = 0u32;
410    let mut seen_versions = std::collections::HashSet::new();
411    seen_versions.insert(0u32);
412
413    for (i, a) in chain.iter().enumerate().skip(1) {
414        // No duplicate versions
415        if !seen_versions.insert(a.new_key_version) {
416            return Err(ChainError::DuplicateVersion(a.new_key_version));
417        }
418        // Strict +1 succession
419        let expected = prev_version + 1;
420        if a.old_key_version != prev_version || a.new_key_version != expected {
421            return Err(ChainError::VersionSkip {
422                expected,
423                got: a.new_key_version,
424            });
425        }
426        // Pubkey continuity
427        if a.old_pubkey != prev_pubkey {
428            return Err(ChainError::PubkeyDiscontinuity {
429                at_version: a.new_key_version,
430            });
431        }
432        // Signature (or emergency allowance)
433        if a.reason == RotationReason::Emergency {
434            if !opts.allow_emergency {
435                return Err(ChainError::EmergencyDisallowed {
436                    at_version: a.new_key_version,
437                });
438            }
439            // Lenient verify: empty signature is fine for emergency
440            if let Err(e) = a.verify_or_emergency(&a.old_pubkey) {
441                return Err(ChainError::BadSignature {
442                    at_version: a.new_key_version,
443                    detail: e.to_string(),
444                });
445            }
446        } else if let Err(e) = a.verify(&a.old_pubkey) {
447            return Err(ChainError::BadSignature {
448                at_version: a.new_key_version,
449                detail: e.to_string(),
450            });
451        }
452
453        prev_pubkey = a.new_pubkey.clone();
454        prev_version = a.new_key_version;
455        let _ = i; // silence unused
456    }
457
458    Ok(ChainOutcome {
459        head_key_version: prev_version,
460        head_pubkey: prev_pubkey,
461        length: chain.len(),
462    })
463}
464
465/// Canonical JSON: sorted keys, no whitespace. Used so that signers and
466/// verifiers compute identical byte sequences regardless of language /
467/// serializer choices.
468fn canonical_json<T: serde::Serialize>(value: &T) -> Vec<u8> {
469    // serde_json with a BTreeMap-like ordering. The simplest approach: round-trip
470    // through a `serde_json::Value`, then walk it depth-first emitting bytes.
471    let v: serde_json::Value =
472        serde_json::to_value(value).expect("serialize should not fail for our types");
473    let mut out = Vec::new();
474    write_canonical(&mut out, &v);
475    out
476}
477
478fn write_canonical(out: &mut Vec<u8>, v: &serde_json::Value) {
479    use serde_json::Value;
480    match v {
481        Value::Null => out.extend_from_slice(b"null"),
482        Value::Bool(b) => out.extend_from_slice(if *b { b"true" } else { b"false" }),
483        Value::Number(n) => out.extend_from_slice(n.to_string().as_bytes()),
484        Value::String(s) => {
485            // serde_json::to_string handles escaping for us
486            let escaped = serde_json::to_string(s).unwrap();
487            out.extend_from_slice(escaped.as_bytes());
488        }
489        Value::Array(arr) => {
490            out.push(b'[');
491            for (i, item) in arr.iter().enumerate() {
492                if i > 0 {
493                    out.push(b',');
494                }
495                write_canonical(out, item);
496            }
497            out.push(b']');
498        }
499        Value::Object(map) => {
500            // Sort keys for deterministic output
501            let mut keys: Vec<&String> = map.keys().collect();
502            keys.sort();
503            out.push(b'{');
504            for (i, k) in keys.iter().enumerate() {
505                if i > 0 {
506                    out.push(b',');
507                }
508                let kesc = serde_json::to_string(k).unwrap();
509                out.extend_from_slice(kesc.as_bytes());
510                out.push(b':');
511                write_canonical(out, &map[*k]);
512            }
513            out.push(b'}');
514        }
515    }
516}