//! Traceable identity for principals — human or AI agent, symmetric. //! //! Everything the methodology layer records is signed, and the signing key //! **is** the trace: an op's author, a review's reviewer, a release's //! certifier are all just verifying keys. This crate gives a principal a //! persistent, loadable version of that key, plus the metadata an auditor //! reads off it: a **label** (who it is) and a **kind** (human or agent). //! //! The kind is metadata, never a gate. The whole doctrine is that human and //! agent are symmetric actors — what matters is that an action is attributable //! to a *distinct, identified* principal, not what species that principal is. //! So `ActorKind` rides along for the audit trail; nothing branches on it. //! //! The id is not reinvented. A principal's verifying key is byte-identical to //! a JCP `did:key`, and [`Principal::did_key`] renders the same string JCP's //! `Did` does — one identity, consistent everywhere the family records a //! signer. The private half persists as a keyfile (the actor's secret, written //! `0600`), so a developer or an agent keeps the same identity across sessions //! instead of a per-run placeholder. use std::io; use std::path::Path; use ed25519_dalek::SigningKey; use serde::{Deserialize, Serialize}; /// The ed25519 multicodec prefix (`0xed 0x01`) — the `did:key` tag, matching /// JCP. const ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01]; /// What kind of actor a principal is — metadata for the audit trail, never a /// gate. A human and an agent are equally first-class signers. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum ActorKind { /// A human, or a human acting through their agent — one accountable /// principal either way. Human, /// An autonomous AI agent acting under its own identity. Agent, } impl ActorKind { /// A stable label. pub fn label(self) -> &'static str { match self { ActorKind::Human => "human", ActorKind::Agent => "agent", } } } /// A principal's **public** identity — the trace. The verifying key is the id; /// the label and kind are what an auditor reads off it. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Principal { /// The ed25519 verifying key — byte-identical to a JCP `did:key`, and to /// the `author` / `reviewer` / `certifier` fields of the methodology /// artifacts. pub key: [u8; 32], /// Who this is (a name, an email, an agent id). pub label: String, /// Human or agent. pub kind: ActorKind, } impl Principal { /// The `did:key:z…` string — the same encoding JCP's `Did` produces /// (multibase base58btc of multicodec ‖ key), so one principal is one /// identity across the whole family. pub fn did_key(&self) -> String { did_key(&self.key) } /// A short hex of the key, for compact display (`8139770e…`). pub fn short(&self) -> String { let hex: String = self.key[..4].iter().map(|b| format!("{b:02x}")).collect(); format!("{hex}…") } } /// The `did:key:z…` string for a raw verifying key — the same encoding JCP's /// `Did` produces. Free function so an auditor can render any key (e.g. an /// artifact's `author` field) without constructing a [`Principal`]. pub fn did_key(key: &[u8; 32]) -> String { let mut mc = ED25519_MULTICODEC.to_vec(); mc.extend_from_slice(key); format!("did:key:z{}", bs58::encode(mc).into_string()) } /// A **roster** — a naming convention mapping known principals' keys to their /// labels and kinds, so an audit can read "alice (human)" rather than a raw /// `did:key`. Persistable as plain JSON, like a contacts file. /// /// This is a *naming* layer, not a *trust* layer: the key is the authoritative /// trace, and a roster entry is a claim by whoever maintains the file that a /// key belongs to a given label — asserted, not cryptographically proven (the /// same status as `/etc/hosts` or SSH `known_hosts`). Resolution never changes /// what an artifact verifies; it only renders who a verified key is *called*. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Roster { /// The known principals. pub principals: Vec, } impl Roster { /// Register a principal, de-duplicating on key (a later entry for the same /// key replaces the label/kind — the roster's maintainer has the last /// word on naming). pub fn register(&mut self, principal: Principal) { self.principals.retain(|p| p.key != principal.key); self.principals.push(principal); } /// Resolve a verifying key to its known principal, if the roster names it. pub fn resolve(&self, key: &[u8; 32]) -> Option<&Principal> { self.principals.iter().find(|p| &p.key == key) } /// Resolve a `did:key:z…` string to its known principal. pub fn resolve_did(&self, did: &str) -> Option<&Principal> { self.principals.iter().find(|p| p.did_key() == did) } /// A human-readable description of a key for an audit line: `"alice (human, /// did:key:z…)"` when known, or `"unknown (did:key:z…)"` when not. Never /// fails — an unknown signer is still fully traced by its did:key. pub fn describe(&self, key: &[u8; 32]) -> String { match self.resolve(key) { Some(p) => format!("{} ({}, {})", p.label, p.kind.label(), p.did_key()), None => format!("unknown ({})", did_key(key)), } } /// Load a roster from a JSON file, or an empty roster if absent. pub fn load_or_default(path: &Path) -> Self { std::fs::read_to_string(path) .ok() .and_then(|t| serde_json::from_str(&t).ok()) .unwrap_or_default() } /// Write the roster to a JSON file (creating parent directories). A roster /// holds only public keys and labels — no secrets — so it is not /// permission-restricted. pub fn save(&self, path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let json = serde_json::to_string_pretty(self) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; std::fs::write(path, json) } } /// A principal's **full** identity, including its signing key. Hold one of /// these to act *as* the principal; share its [`Principal`] to be traced. pub struct Identity { signing: SigningKey, label: String, kind: ActorKind, } /// The on-disk keyfile form. The `secret` is the 32-byte signing seed in hex — /// the actor's secret; the file is written `0600`. #[derive(Serialize, Deserialize)] struct KeyFile { kind: ActorKind, label: String, secret: String, } impl Identity { /// A fresh identity with a random key (OS CSPRNG). pub fn generate(label: impl Into, kind: ActorKind) -> Self { let signing = SigningKey::generate(&mut rand_core::OsRng); Self { signing, label: label.into(), kind } } /// A deterministic identity from a 32-byte seed — for tests and /// reproducible fixtures, not for issuing real identities. pub fn from_seed(seed: [u8; 32], label: impl Into, kind: ActorKind) -> Self { Self { signing: SigningKey::from_bytes(&seed), label: label.into(), kind } } /// This identity's public [`Principal`] — the trace to share. pub fn principal(&self) -> Principal { Principal { key: self.signing.verifying_key().to_bytes(), label: self.label.clone(), kind: self.kind, } } /// The signing key, to sign artifacts as this principal. pub fn signing_key(&self) -> &SigningKey { &self.signing } /// The verifying-key bytes — the value that lands in an artifact's /// `author` / `reviewer` / `certifier` field. pub fn verifying_key(&self) -> [u8; 32] { self.signing.verifying_key().to_bytes() } /// The label. pub fn label(&self) -> &str { &self.label } /// The kind. pub fn kind(&self) -> ActorKind { self.kind } /// Write the identity to a keyfile (`0600` on Unix). Creates parent /// directories. pub fn save(&self, path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let file = KeyFile { kind: self.kind, label: self.label.clone(), secret: hex_of(&self.signing.to_bytes()), }; let json = serde_json::to_string_pretty(&file) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; std::fs::write(path, json)?; restrict_permissions(path)?; Ok(()) } /// Load an identity from a keyfile. pub fn load(path: &Path) -> io::Result { let text = std::fs::read_to_string(path)?; let file: KeyFile = serde_json::from_str(&text) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; let seed = hex32(&file.secret) .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "secret is not 64 hex chars"))?; Ok(Self { signing: SigningKey::from_bytes(&seed), label: file.label, kind: file.kind }) } /// The common path: load the identity at `path`, or — if absent — generate /// a fresh one with `label`/`kind`, persist it, and return it. So a /// principal keeps one identity across runs without any registration step. pub fn load_or_create(path: &Path, label: impl Into, kind: ActorKind) -> io::Result { match Self::load(path) { Ok(id) => Ok(id), Err(e) if e.kind() == io::ErrorKind::NotFound => { let id = Self::generate(label, kind); id.save(path)?; Ok(id) } Err(e) => Err(e), } } } fn hex_of(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } fn hex32(s: &str) -> Option<[u8; 32]> { let s = s.trim(); if s.len() != 64 { return None; } let mut out = [0u8; 32]; for (i, b) in out.iter_mut().enumerate() { *b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()?; } Some(out) } #[cfg(unix)] fn restrict_permissions(path: &Path) -> io::Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) } #[cfg(not(unix))] fn restrict_permissions(_path: &Path) -> io::Result<()> { Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn the_did_key_is_byte_identical_to_jcp() { // the id is not reinvented: our did:key must equal JCP's for the same key. let id = Identity::from_seed([7u8; 32], "alice", ActorKind::Human); let p = id.principal(); let jcp = jcp_schema::Did(p.key).to_did_key(); assert_eq!(p.did_key(), jcp); assert!(p.did_key().starts_with("did:key:z")); } #[test] fn an_identity_round_trips_through_a_keyfile() { let dir = std::env::temp_dir().join(format!("jci-{}", std::process::id())); let path = dir.join("identity.json"); let id = Identity::generate("granite-reviewer", ActorKind::Agent); let want = id.principal(); id.save(&path).unwrap(); let back = Identity::load(&path).unwrap(); assert_eq!(back.principal(), want); assert_eq!(back.kind(), ActorKind::Agent); let _ = std::fs::remove_dir_all(&dir); } #[test] fn load_or_create_is_stable_across_calls() { let dir = std::env::temp_dir().join(format!("jci-stable-{}", std::process::id())); let path = dir.join("identity.json"); let a = Identity::load_or_create(&path, "dev", ActorKind::Human).unwrap(); let b = Identity::load_or_create(&path, "ignored-second-time", ActorKind::Agent).unwrap(); // same identity returned, and the label/kind from first creation stick assert_eq!(a.verifying_key(), b.verifying_key()); assert_eq!(b.kind(), ActorKind::Human); assert_eq!(b.label(), "dev"); let _ = std::fs::remove_dir_all(&dir); } #[test] fn distinct_principals_have_distinct_ids() { // separation of duties is between distinct keys — generate gives distinct let a = Identity::generate("author", ActorKind::Human); let b = Identity::generate("reviewer", ActorKind::Agent); assert_ne!(a.verifying_key(), b.verifying_key()); } #[test] fn a_roster_resolves_keys_to_names_for_audit_display() { let alice = Identity::from_seed([1u8; 32], "alice", ActorKind::Human); let bot = Identity::from_seed([2u8; 32], "granite-reviewer", ActorKind::Agent); let mut roster = Roster::default(); roster.register(alice.principal()); roster.register(bot.principal()); // a known key renders its label, kind, and did:key let d = roster.describe(&alice.verifying_key()); assert!(d.starts_with("alice (human, did:key:z"), "{d}"); // resolve by did:key string too assert_eq!(roster.resolve_did(&bot.principal().did_key()).unwrap().label, "granite-reviewer"); // an unknown signer is still fully traced by its did:key, just unnamed let stranger = Identity::from_seed([9u8; 32], "x", ActorKind::Agent); let d = roster.describe(&stranger.verifying_key()); assert!(d.starts_with("unknown (did:key:z"), "{d}"); assert_eq!(d, format!("unknown ({})", did_key(&stranger.verifying_key()))); } #[test] fn a_roster_round_trips_and_renaming_has_the_last_word() { let id = Identity::from_seed([1u8; 32], "old-name", ActorKind::Human); let mut roster = Roster::default(); roster.register(id.principal()); // re-register the same KEY with a new label → replaces, not duplicates let mut renamed = id.principal(); renamed.label = "alice@openie.dev".into(); roster.register(renamed); assert_eq!(roster.principals.len(), 1); assert_eq!(roster.resolve(&id.verifying_key()).unwrap().label, "alice@openie.dev"); let dir = std::env::temp_dir().join(format!("jci-roster-{}", std::process::id())); let path = dir.join("roster.json"); roster.save(&path).unwrap(); let back = Roster::load_or_default(&path); assert_eq!(back.resolve(&id.verifying_key()).unwrap().label, "alice@openie.dev"); // a missing file is an empty roster, not an error assert!(Roster::load_or_default(&dir.join("nope.json")).principals.is_empty()); let _ = std::fs::remove_dir_all(&dir); } }