//! Who is who (spec §8): the owner, the instance, and agents, each an Oxygen //! identity; an agent is valid while the owner's current identity delegates //! to its key (oxygen spec §14.2). Everything here is derived from the //! ledger, and every claim in it was proven when it was recorded: //! //! - `identity.owner` and `identity.rotated` are signed by the identity they //! record, so only the holder of its secret key can name it the owner. //! A successor must keep the key pair and chain back, by `previous`, to //! the owner already known; a fresh key pair needs `oxy identity rotate`'s //! attestation, which 0.1 does not accept yet. //! - `identity.instance` is signed by the instance key. //! - `agent.added` and `agent.disabled` are signed by the owner, and an //! agent is added only while the owner's identity delegates to it. //! //! [`Principals`] replays these in ledger order, so `spectra admin verify` //! checks every signed event against who was who at that moment. use crate::event::{Event, Method}; use crate::identity::Identity; use crate::identity::KeyId; use crate::ledger::KeyResolver; use crate::proof::Proof; use serde_json::Value as Json; use sqlx::SqliteConnection; use std::collections::HashMap; /// The scopes a new agent gets (spec §8.4). pub const DEFAULT_SCOPES: &[&str] = &["read", "say", "tasks", "claims", "decisions", "status", "search", "schedule:self"]; /// Who caused something. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Principal { Owner { key: KeyId, name: String }, Agent { name: String, key: KeyId, owner_name: String, uid: Option, learn_uid: bool, project: String, scopes: Vec }, Instance { key: KeyId }, } impl Principal { /// The `actor` events carry: `human`, `agent:NAME` or `system`. pub fn actor(&self) -> String { match self { Principal::Owner { .. } => "human".into(), Principal::Agent { name, .. } => format!("agent:{name}"), Principal::Instance { .. } => "system".into(), } } /// How it is shown to people: `claude-oxygen · Andy McCall's agent`. pub fn display(&self) -> String { match self { Principal::Owner { name, .. } => name.clone(), Principal::Agent { name, owner_name, .. } => format!("{name} · {owner_name}'s agent"), Principal::Instance { .. } => "Spectra".into(), } } } #[derive(Clone, Debug)] struct AgentRec { name: String, identity: Identity, owner_name: String, uid: Option, learn_uid: bool, project: String, scopes: Vec, disabled: bool, } /// The principals as of some point in the ledger. #[derive(Clone, Debug, Default)] pub struct Principals { /// The owner's identities, oldest first; the last is current. owner: Vec, instance: Option, agents: HashMap, by_name: HashMap, } impl Principals { /// Replay every identity and agent event of the ledger. pub async fn load(ledger: &crate::ledger::Ledger) -> anyhow::Result { let mut p = Principals::default(); let mut from = 1; loop { let rows = ledger .events_of_kinds( from, &["identity.owner", "identity.rotated", "identity.instance", "agent.added", "agent.disabled", "agent.scoped"], 1000, ) .await?; if rows.is_empty() { break; } for e in &rows { // Recorded facts were checked when written; `verify` checks them again. let _ = p.observe(e); } from = rows.last().map(|e| e.seq + 1).unwrap_or(from); } Ok(p) } /// The owner's current identity. pub fn owner(&self) -> Option<&Identity> { self.owner.last() } pub fn instance(&self) -> Option<&Identity> { self.instance.as_ref() } /// The agent named `name`, whatever its state. pub fn agent_key(&self, name: &str) -> Option { self.by_name.get(name).copied() } /// Who `key` is at `at_ms`, if anyone may act with it then: the owner /// (any of its identities' KeyIds), the instance, or an agent that is /// not disabled and that the owner's current identity delegates to. pub fn principal(&self, key: &KeyId, at_ms: u64) -> Result { if let Some(owner) = self.owner.iter().rev().find(|i| &i.hash() == key) { return Ok(Principal::Owner { key: *key, name: owner.name.clone() }); } if self.instance.as_ref().is_some_and(|i| &i.hash() == key) { return Ok(Principal::Instance { key: *key }); } let a = self.agents.get(key).ok_or("an unknown key")?; if a.disabled { return Err(format!("agent {} is disabled", a.name)); } let owner = self.owner().ok_or("no owner is recorded")?; if !owner.delegates_to(key, at_ms) { return Err(match owner.delegate(key) { Some(d) if d.expires.is_some_and(|x| at_ms > x) => format!("agent {}'s delegation from {} has expired", a.name, owner.name), Some(_) => format!("{}'s delegation to agent {} does not include `changes`", owner.name, a.name), None => format!("{}'s current identity does not delegate to agent {}", owner.name, a.name), }); } Ok(Principal::Agent { name: a.name.clone(), key: *key, owner_name: a.owner_name.clone(), uid: a.uid, learn_uid: a.learn_uid, project: a.project.clone(), scopes: a.scopes.clone(), }) } /// The identity behind `key`, for checking its signatures. pub fn identity(&self, key: &KeyId) -> Option<&Identity> { self.owner .iter() .find(|i| &i.hash() == key) .or_else(|| self.instance.as_ref().filter(|i| &i.hash() == key)) .or_else(|| self.agents.get(key).map(|a| &a.identity)) } /// Take one event into account, checking what it claims first. pub fn observe(&mut self, e: &Event) -> Result<(), String> { let p = e.json(); match e.kind.as_str() { "identity.owner" => { if !self.owner.is_empty() { return Err("an owner is already recorded".into()); } let id = identity_of(&p)?; self_signed(e, &id)?; self.owner.push(id); } "identity.rotated" => { let new = identity_of(&p)?; let cur = self.owner().ok_or("no owner to succeed")?.clone(); accept_successor(&cur, &new, &p)?; self_signed(e, &new)?; self.owner.push(new); } "identity.instance" => { let id = identity_of(&p)?; self_signed(e, &id)?; self.instance = Some(id); } "agent.added" => { let id = identity_of(&p)?; let key = id.hash(); let owner = self.owner_signed(e)?; if !owner.delegates_to(&key, e.hlc.wall_ms) { return Err("added without the owner's delegation to its key".into()); } let name = text(&p, "name")?; if self.by_name.get(&name).is_some_and(|k| *k != key) { return Err(format!("agent {name} already exists with another key")); } self.by_name.insert(name.clone(), key); self.agents.insert( key, AgentRec { name, identity: id, owner_name: owner.name.clone(), uid: p.get("uid").and_then(Json::as_u64).map(|u| u as u32), learn_uid: p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false), project: text(&p, "project")?, scopes: p .get("scopes") .and_then(Json::as_array) .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()) .unwrap_or_default(), disabled: false, }, ); } "agent.uid_bound" => { // The daemon binds a learning agent to the uid of its first // signed connection; covered by the next checkpoint. let name = text(&p, "name")?; let uid = p.get("uid").and_then(Json::as_u64).ok_or("no uid")? as u32; let key = *self.by_name.get(&name).ok_or("an unknown agent")?; if let Some(a) = self.agents.get_mut(&key) { if a.uid.is_none() { a.uid = Some(uid); } } } "agent.disabled" => { self.owner_signed(e)?; let name = text(&p, "name")?; let key = *self.by_name.get(&name).ok_or("an unknown agent")?; if let Some(a) = self.agents.get_mut(&key) { a.disabled = true; } } // The owner grants or takes back one scope (spec §8.4), such // as `channel:NAME` to post in another project's channel. "agent.scoped" => { self.owner_signed(e)?; let name = text(&p, "name")?; let scope = text(&p, "scope")?; if !grantable(&scope) { return Err(format!("{scope} is not a scope an agent may be given")); } let grant = p.get("grant").and_then(Json::as_bool).unwrap_or(true); let key = *self.by_name.get(&name).ok_or("an unknown agent")?; if let Some(a) = self.agents.get_mut(&key) { a.scopes.retain(|s| s != &scope); if grant { a.scopes.push(scope); } } } _ => {} } Ok(()) } /// Check that `e` is signed by the owner's current identity; returns it. fn owner_signed(&self, e: &Event) -> Result { let owner = self.owner().ok_or("no owner is recorded")?.clone(); if e.auth.key != Some(owner.hash().0) { return Err("not signed by the owner's current identity".into()); } check_proof(e, &owner)?; Ok(owner) } /// An event from a Radar session: its proof is the owner's signed /// request for the sign-in link, so it must verify against the owner, /// and only the owner acts this way. pub fn check_session(&self, e: &Event) -> Result<(), String> { let key = crate::identity::KeyId(e.auth.key.ok_or("a session event names no key")?); if !matches!(self.principal(&key, e.hlc.wall_ms)?, Principal::Owner { .. }) || e.actor != "human" { return Err("only the owner acts through a Radar session".into()); } let identity = self.identity(&key).ok_or("an unknown key")?; let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?; if proof.key() != key.0 || proof.method != "POST /radar/link" { return Err("the session's proof is not the owner's sign-in request".into()); } proof.verify(identity, None) } /// Check a signed event's proof against the principal its key was at /// the event's time. pub fn check_signed(&self, e: &Event) -> Result<(), String> { let key = crate::identity::KeyId(e.auth.key.ok_or("signed, but names no key")?); let who = self.principal(&key, e.hlc.wall_ms)?; // The actor an event names must be the principal that signed it: // an agent's key never makes an event in another's name. if who.actor() != e.actor { return Err(format!("names {} as its actor but is signed by {}", e.actor, who.display())); } let identity = self.identity(&key).ok_or("an unknown key")?; check_proof(e, identity) } } impl KeyResolver for Principals { fn check(&mut self, e: &Event) -> Result<(), String> { match e.kind.as_str() { "identity.owner" | "identity.rotated" | "identity.instance" | "agent.added" | "agent.disabled" | "agent.scoped" => { if e.auth.method != Method::Signature { return Err(format!("{} must be signed", e.kind)); } self.observe(e) } "agent.uid_bound" => self.observe(e), _ if matches!(e.auth.method, Method::Signature | Method::Passkey) => self.check_signed(e), _ if e.auth.method == Method::Session => self.check_session(e), _ => Ok(()), } } } /// Apply identity and agent events to the projection tables. pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { if e.kind == "agent.uid_bound" { let p = e.json(); sqlx::query("UPDATE agents SET uid = ? WHERE name = ? AND uid IS NULL") .bind(p.get("uid").and_then(Json::as_i64)) .bind(p.get("name").and_then(Json::as_str)) .execute(&mut *conn) .await?; return Ok(()); } // Who is who is only ever said by a signature; `verify` checks which. if e.auth.method != Method::Signature { return Ok(()); } let p = e.json(); let id = |p: &Json| identity_of(p).ok(); match e.kind.as_str() { "identity.owner" | "identity.rotated" | "identity.instance" => { let Some(identity) = id(&p) else { return Ok(()) }; let role = if e.kind == "identity.instance" { "instance" } else { "owner" }; sqlx::query("UPDATE identities SET current = 0 WHERE role = ?").bind(role).execute(&mut *conn).await?; sqlx::query( "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, ?, ?, ?, ?, 1, ?)", ) .bind(identity.hash().to_hex()) .bind(role) .bind(&identity.name) .bind(identity.encode()) .bind(identity.previous.map(|k| k.to_hex())) .bind(e.seq as i64) .execute(&mut *conn) .await?; } "agent.added" => { let Some(identity) = id(&p) else { return Ok(()) }; if p.get("name").and_then(Json::as_str).is_none_or(str::is_empty) { return Ok(()); } let s = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or("").to_string(); sqlx::query( "INSERT OR REPLACE INTO agents (name, key, owner, owner_name, kind, project, home, uid, learn_uid, scopes, created_ms, disabled_ms, seq) \ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", ) .bind(s("name")) .bind(identity.hash().to_hex()) .bind(s("owner")) .bind(s("owner_name")) .bind(s("kind")) .bind(s("project")) .bind(s("home")) .bind(p.get("uid").and_then(Json::as_i64)) .bind(p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false)) .bind(p.get("scopes").map(|v| v.to_string()).unwrap_or_else(|| "[]".into())) .bind(e.at_ms as i64) .bind(e.seq as i64) .execute(&mut *conn) .await?; sqlx::query( "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, 'agent', ?, ?, ?, 1, ?)", ) .bind(identity.hash().to_hex()) .bind(&identity.name) .bind(identity.encode()) .bind(identity.previous.map(|k| k.to_hex())) .bind(e.seq as i64) .execute(&mut *conn) .await?; } "agent.disabled" => { sqlx::query("UPDATE agents SET disabled_ms = ? WHERE name = ?") .bind(e.at_ms as i64) .bind(p.get("name").and_then(Json::as_str)) .execute(&mut *conn) .await?; } "agent.scoped" if e.actor == "human" => { let (Some(name), Some(scope)) = (p.get("name").and_then(Json::as_str), p.get("scope").and_then(Json::as_str)) else { return Ok(()); }; if !grantable(scope) { return Ok(()); } let grant = p.get("grant").and_then(Json::as_bool).unwrap_or(true); let current: Option = sqlx::query_scalar("SELECT scopes FROM agents WHERE name = ?").bind(name).fetch_optional(&mut *conn).await?; let mut scopes: Vec = current.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(); scopes.retain(|s| s != scope); if grant { scopes.push(scope.to_string()); } sqlx::query("UPDATE agents SET scopes = ? WHERE name = ?") .bind(serde_json::to_string(&scopes).unwrap_or_default()) .bind(name) .execute(&mut *conn) .await?; } _ => {} } Ok(()) } /// A scope the owner may give an agent (spec §8.4): never the owner's own /// authority, only reach. pub fn grantable(scope: &str) -> bool { match scope.strip_prefix("channel:") { Some(ch) => !ch.is_empty() && ch.len() <= 64 && ch.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'), None => matches!(scope, "projects:all" | "schedule:any" | "webhooks:read"), } } /// The payload of an identity event for `identity`. pub fn identity_payload(identity: &Identity) -> Json { serde_json::json!({ "key": identity.hash().to_hex(), "name": identity.name, "identity": hex::encode(identity.encode()) }) } fn identity_of(p: &Json) -> Result { let b = p.get("identity").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).ok_or("no identity in the event")?; Identity::decode(&b).map_err(|e| format!("malformed identity: {e}")) } fn text(p: &Json, k: &str) -> Result { p.get(k).and_then(Json::as_str).map(String::from).ok_or_else(|| format!("no {k}")) } fn self_signed(e: &Event, id: &Identity) -> Result<(), String> { if e.auth.key != Some(id.hash().0) { return Err("not signed by the identity it records".into()); } check_proof(e, id) } /// The event's proof verifies against `identity`, and a CLI proof covers /// exactly the event's payload. fn check_proof(e: &Event, identity: &Identity) -> Result<(), String> { let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?; if proof.key() != identity.hash().0 { return Err("the proof is by another key".into()); } let body = (proof.method == "cli").then(|| e.payload.as_deref().unwrap_or(b"")); proof.verify(identity, body) } /// A successor of the owner must keep the key pair and chain back to the /// current identity through `previous` (the intermediate identities, if /// any, are carried in the event as `chain`). fn accept_successor(cur: &Identity, new: &Identity, p: &Json) -> Result<(), String> { if new.ed25519_pk != cur.ed25519_pk || new.mldsa65_pk != cur.mldsa65_pk { return Err("a successor with a fresh key pair needs `oxy identity rotate`'s attestation, not accepted in 0.1".into()); } let chain: Vec = p .get("chain") .and_then(Json::as_array) .map(|a| a.iter().filter_map(|h| h.as_str().and_then(|h| hex::decode(h).ok()).and_then(|b| Identity::decode(&b).ok())).collect()) .unwrap_or_default(); let mut at = new.clone(); for _ in 0..=chain.len() { match at.previous { Some(prev) if prev == cur.hash() => return Ok(()), Some(prev) => match chain.iter().find(|i| i.hash() == prev) { Some(i) if i.ed25519_pk == cur.ed25519_pk => at = i.clone(), _ => break, }, None => break, } } Err("does not succeed the recorded owner".into()) } /// An event signed by `pair` from the command line: its proof covers the /// canonical payload, so the event's content is bound to the signature. pub fn signed_event(pair: &crate::keys::KeyPair, actor: &str, kind: &str, payload: Json) -> crate::event::NewEvent { let body = crate::event::canonical_json(&payload); let proof = Proof::sign(pair, "cli", &body); crate::event::NewEvent::system(kind, payload) .by(actor, crate::event::Auth { key: Some(pair.key.0), method: Method::Signature, assurance: 2, proof: Some(proof.encode()) }) }