open-standards/joule-code/joule-code-rs/crates/joule-code-identity/src/lib.rs
Transaction Science caaabc8da1 joule-code-identity: a Roster — resolve did:key → {label, kind} for audit
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]>
2026-06-11 19:27:37 -04:00

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);
}
}