An audit reading raw signing keys is correct but unreadable. The Roster maps known principals' keys to their labels and kinds, so a record renders "alice (human)" / "granite-reviewer (agent)" instead of a bare did:key — while an unknown signer stays fully traced by its did:key, just unnamed. It is a NAMING layer, not a TRUST layer, and the docs say so plainly: the key is the authoritative trace; a roster entry is a claim by whoever maintains the file that a key belongs to a label — asserted, not cryptographically proven (the status of /etc/hosts or SSH known_hosts). Resolution never changes what an artifact verifies; it only renders who a verified key is called. register() dedupes on key (the maintainer's latest naming wins); resolve / resolve_did / describe read it; load_or_default / save persist it as plain JSON (public keys and labels only — no secrets). Plus a free did_key(&[u8;32]) so any artifact field can be rendered without constructing a Principal. Runnable: `cargo run -p joule-code-identity --example audit` resolves an author and two reviewer keys to names, and shows an unknown signer still traced. 6 tests; example + clippy clean. Co-Authored-By: Claude Opus 4.8 <[email protected]>
377 lines
14 KiB
Rust
377 lines
14 KiB
Rust
//! 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<Principal>,
|
|
}
|
|
|
|
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<String>, 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<String>, 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<Self> {
|
|
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<String>, kind: ActorKind) -> io::Result<Self> {
|
|
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 = "[email protected]".into();
|
|
roster.register(renamed);
|
|
assert_eq!(roster.principals.len(), 1);
|
|
assert_eq!(roster.resolve(&id.verifying_key()).unwrap().label, "[email protected]");
|
|
|
|
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, "[email protected]");
|
|
// 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);
|
|
}
|
|
}
|