Identities instead of tokens: init, owned agents as delegates, verified replay of identities, request authentication, one writer (#4) 09752034b5a8
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key b0ec18e9744c
- When
- · 2026-11-02 22:01 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
09752034b5a89aae173923eeb219013978138a77fbe29c0d491599c95942c270- Depends on
- 258bb3824813 36537112fcac 8dc6df8c4d4e
- Depended on by
- 87f601ccce6d 8d803d858c8c c7161ec1a607 b044d90930d9 98a0fb9f0948 813b6330f3ad 67bfa06a894f eca5af92279b 77df591d5a2f b576d05cf862 d17cbc7069ce da84e98f104e 39f5c913f617 037c45d8093c 571572f5fb2c 5eb57578f4ef 2fc88a7b3a5c 5d695d869fbf
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 61 atoms, 8 tree operations, 20 files
Browse the tree at this change
added crates/spectra-core/migrations/0002_principals.sql +27 −0
| 1 | + | -- Projections of who is who (spec §8): identities the ledger knows, by |
|
| 2 | + | -- role, and agents with their owner and delegation. |
|
| 3 | + | CREATE TABLE identities ( |
|
| 4 | + | key TEXT PRIMARY KEY, -- KeyId, hex |
|
| 5 | + | role TEXT NOT NULL, -- owner | instance | agent |
|
| 6 | + | name TEXT NOT NULL, |
|
| 7 | + | identity BLOB NOT NULL, -- the encoded identity object |
|
| 8 | + | previous TEXT, -- the identity it succeeds |
|
| 9 | + | current INTEGER NOT NULL DEFAULT 1, -- 0 once succeeded |
|
| 10 | + | seq INTEGER NOT NULL |
|
| 11 | + | ); |
|
| 12 | + | ||
| 13 | + | CREATE TABLE agents ( |
|
| 14 | + | name TEXT PRIMARY KEY, |
|
| 15 | + | key TEXT NOT NULL UNIQUE, |
|
| 16 | + | owner TEXT NOT NULL, -- the owner's KeyId when added |
|
| 17 | + | owner_name TEXT NOT NULL, |
|
| 18 | + | kind TEXT NOT NULL, |
|
| 19 | + | project TEXT NOT NULL, |
|
| 20 | + | home TEXT NOT NULL, |
|
| 21 | + | uid INTEGER, |
|
| 22 | + | learn_uid INTEGER NOT NULL DEFAULT 0, |
|
| 23 | + | scopes TEXT NOT NULL, |
|
| 24 | + | created_ms INTEGER NOT NULL, |
|
| 25 | + | disabled_ms INTEGER, |
|
| 26 | + | seq INTEGER NOT NULL |
|
| 27 | + | ); |
added crates/spectra-core/src/authn.rs +92 −0
| 1 | + | //! Authenticating a request (spec §8.5): a signature by a known key over the |
|
| 2 | + | //! method, the body, a time within five minutes and a nonce not seen in |
|
| 3 | + | //! that window, from a key the owner delegates to now, and, when the agent |
|
| 4 | + | //! is bound to a Unix user, from that user. What it returns becomes the |
|
| 5 | + | //! `auth` of every event the request causes. |
|
| 6 | + | ||
| 7 | + | use crate::event::{Auth, Method}; |
|
| 8 | + | use crate::principals::{Principal, Principals}; |
|
| 9 | + | use crate::proof::{Proof, WINDOW_MS}; |
|
| 10 | + | use std::collections::HashMap; |
|
| 11 | + | use std::sync::Mutex; |
|
| 12 | + | ||
| 13 | + | /// Why a request was refused. |
|
| 14 | + | #[derive(Debug, PartialEq, Eq, thiserror::Error)] |
|
| 15 | + | pub enum Refusal { |
|
| 16 | + | #[error("the request is not signed")] |
|
| 17 | + | Unsigned, |
|
| 18 | + | #[error("the signature is for {0}, not this request")] |
|
| 19 | + | WrongMethod(String), |
|
| 20 | + | #[error("the request's time is {0} s from the server's: check the clock")] |
|
| 21 | + | Stale(i64), |
|
| 22 | + | #[error("the request's nonce was used before: a replay")] |
|
| 23 | + | Replay, |
|
| 24 | + | #[error("{0}")] |
|
| 25 | + | NotAPrincipal(String), |
|
| 26 | + | #[error("{0}")] |
|
| 27 | + | BadSignature(String), |
|
| 28 | + | /// The key is bound to another Unix user (logged as `guard.impersonation`). |
|
| 29 | + | #[error("agent {agent}'s key is bound to uid {bound}, used from uid {from}")] |
|
| 30 | + | WrongUid { agent: String, bound: u32, from: u32 }, |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | /// The outcome: who, and the `auth` for their events. |
|
| 34 | + | #[derive(Clone, Debug)] |
|
| 35 | + | pub struct Caller { |
|
| 36 | + | pub principal: Principal, |
|
| 37 | + | pub auth: Auth, |
|
| 38 | + | /// The agent asked for its uid to be learnt and has none yet: the |
|
| 39 | + | /// caller records `agent.uid_bound` with this uid. |
|
| 40 | + | pub learn_uid: Option<u32>, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | /// Remembers nonces for the window. |
|
| 44 | + | #[derive(Debug, Default)] |
|
| 45 | + | pub struct Authenticator { |
|
| 46 | + | nonces: Mutex<HashMap<[u8; 16], u64>>, |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | impl Authenticator { |
|
| 50 | + | pub fn authenticate( |
|
| 51 | + | &self, |
|
| 52 | + | principals: &Principals, |
|
| 53 | + | proof: Option<&Proof>, |
|
| 54 | + | method: &str, |
|
| 55 | + | body: &[u8], |
|
| 56 | + | now_ms: u64, |
|
| 57 | + | peer_uid: Option<u32>, |
|
| 58 | + | ) -> Result<Caller, Refusal> { |
|
| 59 | + | let proof = proof.ok_or(Refusal::Unsigned)?; |
|
| 60 | + | if proof.method != method { |
|
| 61 | + | return Err(Refusal::WrongMethod(proof.method.clone())); |
|
| 62 | + | } |
|
| 63 | + | let skew = proof.time_ms as i64 - now_ms as i64; |
|
| 64 | + | if skew.unsigned_abs() > WINDOW_MS { |
|
| 65 | + | return Err(Refusal::Stale(skew / 1000)); |
|
| 66 | + | } |
|
| 67 | + | let key = oxy_core::hash::ObjectRef(proof.key()); |
|
| 68 | + | let principal = principals.principal(&key, now_ms).map_err(Refusal::NotAPrincipal)?; |
|
| 69 | + | let identity = principals.identity(&key).ok_or_else(|| Refusal::NotAPrincipal("an unknown key".into()))?; |
|
| 70 | + | proof.verify(identity, Some(body)).map_err(Refusal::BadSignature)?; |
|
| 71 | + | let mut learn_uid = None; |
|
| 72 | + | if let Principal::Agent { name, uid, learn_uid: learn, .. } = &principal { |
|
| 73 | + | match (uid, peer_uid) { |
|
| 74 | + | (Some(bound), Some(from)) if *bound != from => return Err(Refusal::WrongUid { agent: name.clone(), bound: *bound, from }), |
|
| 75 | + | (None, Some(from)) if *learn => learn_uid = Some(from), |
|
| 76 | + | _ => {} |
|
| 77 | + | } |
|
| 78 | + | } |
|
| 79 | + | { |
|
| 80 | + | let mut seen = self.nonces.lock().unwrap(); |
|
| 81 | + | seen.retain(|_, t| now_ms.saturating_sub(*t) <= 2 * WINDOW_MS); |
|
| 82 | + | if seen.insert(proof.nonce, now_ms).is_some() { |
|
| 83 | + | return Err(Refusal::Replay); |
|
| 84 | + | } |
|
| 85 | + | } |
|
| 86 | + | Ok(Caller { |
|
| 87 | + | principal, |
|
| 88 | + | auth: Auth { key: Some(proof.key()), method: Method::Signature, assurance: 2, proof: Some(proof.encode()) }, |
|
| 89 | + | learn_uid, |
|
| 90 | + | }) |
|
| 91 | + | } |
|
| 92 | + | } |
added crates/spectra-core/src/principals.rs +396 −0
| 1 | + | //! Who is who (spec §8): the owner, the instance, and agents, each an Oxygen |
|
| 2 | + | //! identity; an agent is valid while the owner's current identity delegates |
|
| 3 | + | //! to its key (oxygen spec §14.2). Everything here is derived from the |
|
| 4 | + | //! ledger, and every claim in it was proven when it was recorded: |
|
| 5 | + | //! |
|
| 6 | + | //! - `identity.owner` and `identity.rotated` are signed by the identity they |
|
| 7 | + | //! record, so only the holder of its secret key can name it the owner. |
|
| 8 | + | //! A successor must keep the key pair and chain back, by `previous`, to |
|
| 9 | + | //! the owner already known; a fresh key pair needs `oxy identity rotate`'s |
|
| 10 | + | //! attestation, which 0.1 does not accept yet. |
|
| 11 | + | //! - `identity.instance` is signed by the instance key. |
|
| 12 | + | //! - `agent.added` and `agent.disabled` are signed by the owner, and an |
|
| 13 | + | //! agent is added only while the owner's identity delegates to it. |
|
| 14 | + | //! |
|
| 15 | + | //! [`Principals`] replays these in ledger order, so `spectra admin verify` |
|
| 16 | + | //! checks every signed event against who was who at that moment. |
|
| 17 | + | ||
| 18 | + | use crate::event::{Event, Method}; |
|
| 19 | + | use crate::ledger::KeyResolver; |
|
| 20 | + | use crate::proof::Proof; |
|
| 21 | + | use oxy_core::hash::KeyId; |
|
| 22 | + | use oxy_core::identity::{Identity, Scope}; |
|
| 23 | + | use serde_json::Value as Json; |
|
| 24 | + | use sqlx::SqliteConnection; |
|
| 25 | + | use std::collections::HashMap; |
|
| 26 | + | ||
| 27 | + | /// The scopes a new agent gets (spec §8.4). |
|
| 28 | + | pub const DEFAULT_SCOPES: &[&str] = &["read", "say", "tasks", "claims", "decisions", "status", "search", "schedule:self"]; |
|
| 29 | + | ||
| 30 | + | /// The Oxygen delegation scope an agent's key needs from its owner. |
|
| 31 | + | pub const DELEGATION_SCOPE: Scope = Scope::CHANGES; |
|
| 32 | + | ||
| 33 | + | /// Who caused something. |
|
| 34 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 35 | + | pub enum Principal { |
|
| 36 | + | Owner { key: KeyId, name: String }, |
|
| 37 | + | Agent { name: String, key: KeyId, owner_name: String, uid: Option<u32>, learn_uid: bool, project: String, scopes: Vec<String> }, |
|
| 38 | + | Instance { key: KeyId }, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl Principal { |
|
| 42 | + | /// The `actor` events carry: `human`, `agent:NAME` or `system`. |
|
| 43 | + | pub fn actor(&self) -> String { |
|
| 44 | + | match self { |
|
| 45 | + | Principal::Owner { .. } => "human".into(), |
|
| 46 | + | Principal::Agent { name, .. } => format!("agent:{name}"), |
|
| 47 | + | Principal::Instance { .. } => "system".into(), |
|
| 48 | + | } |
|
| 49 | + | } |
|
| 50 | + | /// How it is shown to people: `claude-oxygen · Andy McCall's agent`. |
|
| 51 | + | pub fn display(&self) -> String { |
|
| 52 | + | match self { |
|
| 53 | + | Principal::Owner { name, .. } => name.clone(), |
|
| 54 | + | Principal::Agent { name, owner_name, .. } => format!("{name} · {owner_name}'s agent"), |
|
| 55 | + | Principal::Instance { .. } => "Spectra".into(), |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | #[derive(Clone, Debug)] |
|
| 61 | + | struct AgentRec { |
|
| 62 | + | name: String, |
|
| 63 | + | identity: Identity, |
|
| 64 | + | owner_name: String, |
|
| 65 | + | uid: Option<u32>, |
|
| 66 | + | learn_uid: bool, |
|
| 67 | + | project: String, |
|
| 68 | + | scopes: Vec<String>, |
|
| 69 | + | disabled: bool, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | /// The principals as of some point in the ledger. |
|
| 73 | + | #[derive(Clone, Debug, Default)] |
|
| 74 | + | pub struct Principals { |
|
| 75 | + | /// The owner's identities, oldest first; the last is current. |
|
| 76 | + | owner: Vec<Identity>, |
|
| 77 | + | instance: Option<Identity>, |
|
| 78 | + | agents: HashMap<KeyId, AgentRec>, |
|
| 79 | + | by_name: HashMap<String, KeyId>, |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | impl Principals { |
|
| 83 | + | /// Replay every identity and agent event of the ledger. |
|
| 84 | + | pub async fn load(ledger: &crate::ledger::Ledger) -> anyhow::Result<Principals> { |
|
| 85 | + | let mut p = Principals::default(); |
|
| 86 | + | let mut from = 1; |
|
| 87 | + | loop { |
|
| 88 | + | let rows = ledger |
|
| 89 | + | .events_of_kinds(from, &["identity.owner", "identity.rotated", "identity.instance", "agent.added", "agent.disabled"], 1000) |
|
| 90 | + | .await?; |
|
| 91 | + | if rows.is_empty() { |
|
| 92 | + | break; |
|
| 93 | + | } |
|
| 94 | + | for e in &rows { |
|
| 95 | + | // Recorded facts were checked when written; `verify` checks them again. |
|
| 96 | + | let _ = p.observe(e); |
|
| 97 | + | } |
|
| 98 | + | from = rows.last().map(|e| e.seq + 1).unwrap_or(from); |
|
| 99 | + | } |
|
| 100 | + | Ok(p) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | /// The owner's current identity. |
|
| 104 | + | pub fn owner(&self) -> Option<&Identity> { |
|
| 105 | + | self.owner.last() |
|
| 106 | + | } |
|
| 107 | + | ||
| 108 | + | pub fn instance(&self) -> Option<&Identity> { |
|
| 109 | + | self.instance.as_ref() |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | /// The agent named `name`, whatever its state. |
|
| 113 | + | pub fn agent_key(&self, name: &str) -> Option<KeyId> { |
|
| 114 | + | self.by_name.get(name).copied() |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | /// Who `key` is at `at_ms`, if anyone may act with it then: the owner |
|
| 118 | + | /// (any of its identities' KeyIds), the instance, or an agent that is |
|
| 119 | + | /// not disabled and that the owner's current identity delegates to. |
|
| 120 | + | pub fn principal(&self, key: &KeyId, at_ms: u64) -> Result<Principal, String> { |
|
| 121 | + | if let Some(owner) = self.owner.iter().rev().find(|i| &i.hash() == key) { |
|
| 122 | + | return Ok(Principal::Owner { key: *key, name: owner.name.clone() }); |
|
| 123 | + | } |
|
| 124 | + | if self.instance.as_ref().is_some_and(|i| &i.hash() == key) { |
|
| 125 | + | return Ok(Principal::Instance { key: *key }); |
|
| 126 | + | } |
|
| 127 | + | let a = self.agents.get(key).ok_or("an unknown key")?; |
|
| 128 | + | if a.disabled { |
|
| 129 | + | return Err(format!("agent {} is disabled", a.name)); |
|
| 130 | + | } |
|
| 131 | + | let owner = self.owner().ok_or("no owner is recorded")?; |
|
| 132 | + | if !owner.delegates_to(key, DELEGATION_SCOPE, at_ms) { |
|
| 133 | + | return Err(match owner.delegate(key) { |
|
| 134 | + | Some(d) if d.expires.is_some_and(|x| at_ms > x) => format!("agent {}'s delegation from {} has expired", a.name, owner.name), |
|
| 135 | + | Some(_) => format!("{}'s delegation to agent {} does not include `changes`", owner.name, a.name), |
|
| 136 | + | None => format!("{}'s current identity does not delegate to agent {}", owner.name, a.name), |
|
| 137 | + | }); |
|
| 138 | + | } |
|
| 139 | + | Ok(Principal::Agent { |
|
| 140 | + | name: a.name.clone(), |
|
| 141 | + | key: *key, |
|
| 142 | + | owner_name: a.owner_name.clone(), |
|
| 143 | + | uid: a.uid, |
|
| 144 | + | learn_uid: a.learn_uid, |
|
| 145 | + | project: a.project.clone(), |
|
| 146 | + | scopes: a.scopes.clone(), |
|
| 147 | + | }) |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | /// The identity behind `key`, for checking its signatures. |
|
| 151 | + | pub fn identity(&self, key: &KeyId) -> Option<&Identity> { |
|
| 152 | + | self.owner |
|
| 153 | + | .iter() |
|
| 154 | + | .find(|i| &i.hash() == key) |
|
| 155 | + | .or_else(|| self.instance.as_ref().filter(|i| &i.hash() == key)) |
|
| 156 | + | .or_else(|| self.agents.get(key).map(|a| &a.identity)) |
|
| 157 | + | } |
|
| 158 | + | ||
| 159 | + | /// Take one event into account, checking what it claims first. |
|
| 160 | + | pub fn observe(&mut self, e: &Event) -> Result<(), String> { |
|
| 161 | + | let p = e.json(); |
|
| 162 | + | match e.kind.as_str() { |
|
| 163 | + | "identity.owner" => { |
|
| 164 | + | if !self.owner.is_empty() { |
|
| 165 | + | return Err("an owner is already recorded".into()); |
|
| 166 | + | } |
|
| 167 | + | let id = identity_of(&p)?; |
|
| 168 | + | self_signed(e, &id)?; |
|
| 169 | + | self.owner.push(id); |
|
| 170 | + | } |
|
| 171 | + | "identity.rotated" => { |
|
| 172 | + | let new = identity_of(&p)?; |
|
| 173 | + | let cur = self.owner().ok_or("no owner to succeed")?.clone(); |
|
| 174 | + | accept_successor(&cur, &new, &p)?; |
|
| 175 | + | self_signed(e, &new)?; |
|
| 176 | + | self.owner.push(new); |
|
| 177 | + | } |
|
| 178 | + | "identity.instance" => { |
|
| 179 | + | let id = identity_of(&p)?; |
|
| 180 | + | self_signed(e, &id)?; |
|
| 181 | + | self.instance = Some(id); |
|
| 182 | + | } |
|
| 183 | + | "agent.added" => { |
|
| 184 | + | let id = identity_of(&p)?; |
|
| 185 | + | let key = id.hash(); |
|
| 186 | + | let owner = self.owner_signed(e)?; |
|
| 187 | + | if !owner.delegates_to(&key, DELEGATION_SCOPE, e.hlc.wall_ms) { |
|
| 188 | + | return Err("added without the owner's delegation to its key".into()); |
|
| 189 | + | } |
|
| 190 | + | let name = text(&p, "name")?; |
|
| 191 | + | if self.by_name.get(&name).is_some_and(|k| *k != key) { |
|
| 192 | + | return Err(format!("agent {name} already exists with another key")); |
|
| 193 | + | } |
|
| 194 | + | self.by_name.insert(name.clone(), key); |
|
| 195 | + | self.agents.insert( |
|
| 196 | + | key, |
|
| 197 | + | AgentRec { |
|
| 198 | + | name, |
|
| 199 | + | identity: id, |
|
| 200 | + | owner_name: owner.name.clone(), |
|
| 201 | + | uid: p.get("uid").and_then(Json::as_u64).map(|u| u as u32), |
|
| 202 | + | learn_uid: p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false), |
|
| 203 | + | project: text(&p, "project")?, |
|
| 204 | + | scopes: p |
|
| 205 | + | .get("scopes") |
|
| 206 | + | .and_then(Json::as_array) |
|
| 207 | + | .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect()) |
|
| 208 | + | .unwrap_or_default(), |
|
| 209 | + | disabled: false, |
|
| 210 | + | }, |
|
| 211 | + | ); |
|
| 212 | + | } |
|
| 213 | + | "agent.disabled" => { |
|
| 214 | + | self.owner_signed(e)?; |
|
| 215 | + | let name = text(&p, "name")?; |
|
| 216 | + | let key = *self.by_name.get(&name).ok_or("an unknown agent")?; |
|
| 217 | + | if let Some(a) = self.agents.get_mut(&key) { |
|
| 218 | + | a.disabled = true; |
|
| 219 | + | } |
|
| 220 | + | } |
|
| 221 | + | _ => {} |
|
| 222 | + | } |
|
| 223 | + | Ok(()) |
|
| 224 | + | } |
|
| 225 | + | ||
| 226 | + | /// Check that `e` is signed by the owner's current identity; returns it. |
|
| 227 | + | fn owner_signed(&self, e: &Event) -> Result<Identity, String> { |
|
| 228 | + | let owner = self.owner().ok_or("no owner is recorded")?.clone(); |
|
| 229 | + | if e.auth.key != Some(owner.hash().0) { |
|
| 230 | + | return Err("not signed by the owner's current identity".into()); |
|
| 231 | + | } |
|
| 232 | + | check_proof(e, &owner)?; |
|
| 233 | + | Ok(owner) |
|
| 234 | + | } |
|
| 235 | + | ||
| 236 | + | /// Check a signed event's proof against the principal its key was at |
|
| 237 | + | /// the event's time. |
|
| 238 | + | pub fn check_signed(&self, e: &Event) -> Result<(), String> { |
|
| 239 | + | let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("signed, but names no key")?); |
|
| 240 | + | let who = self.principal(&key, e.hlc.wall_ms)?; |
|
| 241 | + | // The actor an event names must be the principal that signed it: |
|
| 242 | + | // an agent's key never makes an event in another's name. |
|
| 243 | + | if who.actor() != e.actor { |
|
| 244 | + | return Err(format!("names {} as its actor but is signed by {}", e.actor, who.display())); |
|
| 245 | + | } |
|
| 246 | + | let identity = self.identity(&key).ok_or("an unknown key")?; |
|
| 247 | + | check_proof(e, identity) |
|
| 248 | + | } |
|
| 249 | + | } |
|
| 250 | + | ||
| 251 | + | impl KeyResolver for Principals { |
|
| 252 | + | fn check(&mut self, e: &Event) -> Result<(), String> { |
|
| 253 | + | match e.kind.as_str() { |
|
| 254 | + | "identity.owner" | "identity.rotated" | "identity.instance" | "agent.added" | "agent.disabled" => { |
|
| 255 | + | if e.auth.method != Method::Signature { |
|
| 256 | + | return Err(format!("{} must be signed", e.kind)); |
|
| 257 | + | } |
|
| 258 | + | self.observe(e) |
|
| 259 | + | } |
|
| 260 | + | _ if matches!(e.auth.method, Method::Signature | Method::Passkey) => self.check_signed(e), |
|
| 261 | + | _ => Ok(()), |
|
| 262 | + | } |
|
| 263 | + | } |
|
| 264 | + | } |
|
| 265 | + | ||
| 266 | + | /// Apply identity and agent events to the projection tables. |
|
| 267 | + | pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
|
| 268 | + | let p = e.json(); |
|
| 269 | + | let id = |p: &Json| identity_of(p).ok(); |
|
| 270 | + | match e.kind.as_str() { |
|
| 271 | + | "identity.owner" | "identity.rotated" | "identity.instance" => { |
|
| 272 | + | let Some(identity) = id(&p) else { return Ok(()) }; |
|
| 273 | + | let role = if e.kind == "identity.instance" { "instance" } else { "owner" }; |
|
| 274 | + | sqlx::query("UPDATE identities SET current = 0 WHERE role = ?").bind(role).execute(&mut *conn).await?; |
|
| 275 | + | sqlx::query( |
|
| 276 | + | "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, ?, ?, ?, ?, 1, ?)", |
|
| 277 | + | ) |
|
| 278 | + | .bind(identity.hash().to_hex()) |
|
| 279 | + | .bind(role) |
|
| 280 | + | .bind(&identity.name) |
|
| 281 | + | .bind(identity.encode()) |
|
| 282 | + | .bind(identity.previous.map(|k| k.to_hex())) |
|
| 283 | + | .bind(e.seq as i64) |
|
| 284 | + | .execute(&mut *conn) |
|
| 285 | + | .await?; |
|
| 286 | + | } |
|
| 287 | + | "agent.added" => { |
|
| 288 | + | let Some(identity) = id(&p) else { return Ok(()) }; |
|
| 289 | + | let s = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or("").to_string(); |
|
| 290 | + | sqlx::query( |
|
| 291 | + | "INSERT OR REPLACE INTO agents (name, key, owner, owner_name, kind, project, home, uid, learn_uid, scopes, created_ms, disabled_ms, seq) \ |
|
| 292 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)", |
|
| 293 | + | ) |
|
| 294 | + | .bind(s("name")) |
|
| 295 | + | .bind(identity.hash().to_hex()) |
|
| 296 | + | .bind(s("owner")) |
|
| 297 | + | .bind(s("owner_name")) |
|
| 298 | + | .bind(s("kind")) |
|
| 299 | + | .bind(s("project")) |
|
| 300 | + | .bind(s("home")) |
|
| 301 | + | .bind(p.get("uid").and_then(Json::as_i64)) |
|
| 302 | + | .bind(p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false)) |
|
| 303 | + | .bind(p.get("scopes").map(|v| v.to_string()).unwrap_or_else(|| "[]".into())) |
|
| 304 | + | .bind(e.at_ms as i64) |
|
| 305 | + | .bind(e.seq as i64) |
|
| 306 | + | .execute(&mut *conn) |
|
| 307 | + | .await?; |
|
| 308 | + | sqlx::query( |
|
| 309 | + | "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, 'agent', ?, ?, ?, 1, ?)", |
|
| 310 | + | ) |
|
| 311 | + | .bind(identity.hash().to_hex()) |
|
| 312 | + | .bind(&identity.name) |
|
| 313 | + | .bind(identity.encode()) |
|
| 314 | + | .bind(identity.previous.map(|k| k.to_hex())) |
|
| 315 | + | .bind(e.seq as i64) |
|
| 316 | + | .execute(&mut *conn) |
|
| 317 | + | .await?; |
|
| 318 | + | } |
|
| 319 | + | "agent.disabled" => { |
|
| 320 | + | sqlx::query("UPDATE agents SET disabled_ms = ? WHERE name = ?") |
|
| 321 | + | .bind(e.at_ms as i64) |
|
| 322 | + | .bind(p.get("name").and_then(Json::as_str)) |
|
| 323 | + | .execute(&mut *conn) |
|
| 324 | + | .await?; |
|
| 325 | + | } |
|
| 326 | + | _ => {} |
|
| 327 | + | } |
|
| 328 | + | Ok(()) |
|
| 329 | + | } |
|
| 330 | + | ||
| 331 | + | /// The payload of an identity event for `identity`. |
|
| 332 | + | pub fn identity_payload(identity: &Identity) -> Json { |
|
| 333 | + | serde_json::json!({ "key": identity.hash().to_hex(), "name": identity.name, "identity": hex::encode(identity.encode()) }) |
|
| 334 | + | } |
|
| 335 | + | ||
| 336 | + | fn identity_of(p: &Json) -> Result<Identity, String> { |
|
| 337 | + | let b = p.get("identity").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).ok_or("no identity in the event")?; |
|
| 338 | + | Identity::decode(&b).map_err(|e| format!("malformed identity: {e}")) |
|
| 339 | + | } |
|
| 340 | + | ||
| 341 | + | fn text(p: &Json, k: &str) -> Result<String, String> { |
|
| 342 | + | p.get(k).and_then(Json::as_str).map(String::from).ok_or_else(|| format!("no {k}")) |
|
| 343 | + | } |
|
| 344 | + | ||
| 345 | + | fn self_signed(e: &Event, id: &Identity) -> Result<(), String> { |
|
| 346 | + | if e.auth.key != Some(id.hash().0) { |
|
| 347 | + | return Err("not signed by the identity it records".into()); |
|
| 348 | + | } |
|
| 349 | + | check_proof(e, id) |
|
| 350 | + | } |
|
| 351 | + | ||
| 352 | + | /// The event's proof verifies against `identity`, and a CLI proof covers |
|
| 353 | + | /// exactly the event's payload. |
|
| 354 | + | fn check_proof(e: &Event, identity: &Identity) -> Result<(), String> { |
|
| 355 | + | let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?; |
|
| 356 | + | if proof.key() != identity.hash().0 { |
|
| 357 | + | return Err("the proof is by another key".into()); |
|
| 358 | + | } |
|
| 359 | + | let body = (proof.method == "cli").then(|| e.payload.as_deref().unwrap_or(b"")); |
|
| 360 | + | proof.verify(identity, body) |
|
| 361 | + | } |
|
| 362 | + | ||
| 363 | + | /// A successor of the owner must keep the key pair and chain back to the |
|
| 364 | + | /// current identity through `previous` (the intermediate identities, if |
|
| 365 | + | /// any, are carried in the event as `chain`). |
|
| 366 | + | fn accept_successor(cur: &Identity, new: &Identity, p: &Json) -> Result<(), String> { |
|
| 367 | + | if new.ed25519_pk != cur.ed25519_pk || new.mldsa65_pk != cur.mldsa65_pk { |
|
| 368 | + | return Err("a successor with a fresh key pair needs `oxy identity rotate`'s attestation, not accepted in 0.1".into()); |
|
| 369 | + | } |
|
| 370 | + | let chain: Vec<Identity> = p |
|
| 371 | + | .get("chain") |
|
| 372 | + | .and_then(Json::as_array) |
|
| 373 | + | .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()) |
|
| 374 | + | .unwrap_or_default(); |
|
| 375 | + | let mut at = new.clone(); |
|
| 376 | + | for _ in 0..=chain.len() { |
|
| 377 | + | match at.previous { |
|
| 378 | + | Some(prev) if prev == cur.hash() => return Ok(()), |
|
| 379 | + | Some(prev) => match chain.iter().find(|i| i.hash() == prev) { |
|
| 380 | + | Some(i) if i.ed25519_pk == cur.ed25519_pk => at = i.clone(), |
|
| 381 | + | _ => break, |
|
| 382 | + | }, |
|
| 383 | + | None => break, |
|
| 384 | + | } |
|
| 385 | + | } |
|
| 386 | + | Err("does not succeed the recorded owner".into()) |
|
| 387 | + | } |
|
| 388 | + | ||
| 389 | + | /// An event signed by `pair` from the command line: its proof covers the |
|
| 390 | + | /// canonical payload, so the event's content is bound to the signature. |
|
| 391 | + | pub fn signed_event(pair: &crate::keys::KeyPair, actor: &str, kind: &str, payload: Json) -> crate::event::NewEvent { |
|
| 392 | + | let body = crate::event::canonical_json(&payload); |
|
| 393 | + | let proof = Proof::sign(pair, "cli", &body); |
|
| 394 | + | crate::event::NewEvent::system(kind, payload) |
|
| 395 | + | .by(actor, crate::event::Auth { key: Some(pair.key.0), method: Method::Signature, assurance: 2, proof: Some(proof.encode()) }) |
|
| 396 | + | } |
added crates/spectra-core/src/proof.rs +117 −0
| 1 | + | //! Signed requests and the proofs events carry (spec §8.5, §9.5). |
|
| 2 | + | //! |
|
| 3 | + | //! A principal signs `"spectra1 request " || method || 0x00 || BLAKE3(body) |
|
| 4 | + | //! || time (8 bytes) || nonce (16 bytes)` with its key, through `oxy-core`'s |
|
| 5 | + | //! challenge signing. The signature, with what it covered, is the proof |
|
| 6 | + | //! kept on every event the request causes, so a verifier can check later, |
|
| 7 | + | //! from the ledger alone, which key asked for it. |
|
| 8 | + | //! |
|
| 9 | + | //! The CLI, writing to the ledger itself, signs with `method = "cli"` and |
|
| 10 | + | //! `body` = the event's canonical payload, so its proof also binds the |
|
| 11 | + | //! event's content. |
|
| 12 | + | ||
| 13 | + | use crate::keys::KeyPair; |
|
| 14 | + | use oxy_core::cbor::{self, Value}; |
|
| 15 | + | use oxy_core::identity::{Identity, Signature}; |
|
| 16 | + | use rand::RngCore; |
|
| 17 | + | ||
| 18 | + | /// How far a request's time may be from the server's clock (spec §8.5). |
|
| 19 | + | pub const WINDOW_MS: u64 = 5 * 60 * 1000; |
|
| 20 | + | ||
| 21 | + | /// A signature with everything it covered except the body itself. |
|
| 22 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 23 | + | pub struct Proof { |
|
| 24 | + | pub sig: Signature, |
|
| 25 | + | pub method: String, |
|
| 26 | + | pub body_hash: [u8; 32], |
|
| 27 | + | pub time_ms: u64, |
|
| 28 | + | pub nonce: [u8; 16], |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | impl Proof { |
|
| 32 | + | /// Sign `method` and `body` now with `pair`. |
|
| 33 | + | pub fn sign(pair: &KeyPair, method: &str, body: &[u8]) -> Proof { |
|
| 34 | + | let mut nonce = [0u8; 16]; |
|
| 35 | + | rand::rngs::OsRng.fill_bytes(&mut nonce); |
|
| 36 | + | Proof::sign_at(pair, method, body, crate::ids::now_ms(), nonce) |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | pub fn sign_at(pair: &KeyPair, method: &str, body: &[u8], time_ms: u64, nonce: [u8; 16]) -> Proof { |
|
| 40 | + | let body_hash = *blake3::hash(body).as_bytes(); |
|
| 41 | + | let sig = pair.sign(&message(method, &body_hash, time_ms, &nonce)); |
|
| 42 | + | Proof { sig, method: method.into(), body_hash, time_ms, nonce } |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | /// The signing key's KeyId. |
|
| 46 | + | pub fn key(&self) -> [u8; 32] { |
|
| 47 | + | self.sig.key.0 |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// Check the signature against `identity` and, when given, the body. |
|
| 51 | + | pub fn verify(&self, identity: &Identity, body: Option<&[u8]>) -> Result<(), String> { |
|
| 52 | + | if let Some(b) = body { |
|
| 53 | + | if *blake3::hash(b).as_bytes() != self.body_hash { |
|
| 54 | + | return Err("the signature covers a different body".into()); |
|
| 55 | + | } |
|
| 56 | + | } |
|
| 57 | + | let msg = message(&self.method, &self.body_hash, self.time_ms, &self.nonce); |
|
| 58 | + | oxy_core::identity::verify_challenge(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}")) |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 62 | + | cbor::encode(&Value::map([ |
|
| 63 | + | (0, self.sig.to_value()), |
|
| 64 | + | (1, Value::Text(self.method.clone())), |
|
| 65 | + | (2, Value::Bytes(self.body_hash.to_vec())), |
|
| 66 | + | (3, Value::Uint(self.time_ms)), |
|
| 67 | + | (4, Value::Bytes(self.nonce.to_vec())), |
|
| 68 | + | ])) |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | pub fn decode(b: &[u8]) -> Result<Proof, String> { |
|
| 72 | + | let v = cbor::decode(b).map_err(|e| format!("malformed proof: {e}"))?; |
|
| 73 | + | let m = v.as_map().ok_or("malformed proof")?; |
|
| 74 | + | let get = |k: u64| m.get(&k).ok_or_else(|| format!("proof lacks field {k}")); |
|
| 75 | + | Ok(Proof { |
|
| 76 | + | sig: Signature::from_value(get(0)?).map_err(|e| format!("malformed signature: {e}"))?, |
|
| 77 | + | method: get(1)?.as_text().ok_or("proof method")?.to_string(), |
|
| 78 | + | body_hash: get(2)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof body hash")?, |
|
| 79 | + | time_ms: get(3)?.as_uint().ok_or("proof time")?, |
|
| 80 | + | nonce: get(4)?.as_bytes().and_then(|b| b.try_into().ok()).ok_or("proof nonce")?, |
|
| 81 | + | }) |
|
| 82 | + | } |
|
| 83 | + | } |
|
| 84 | + | ||
| 85 | + | /// `"spectra1 request " || method || 0 || body_hash || time || nonce`. |
|
| 86 | + | pub fn message(method: &str, body_hash: &[u8; 32], time_ms: u64, nonce: &[u8; 16]) -> Vec<u8> { |
|
| 87 | + | let mut m = b"spectra1 request ".to_vec(); |
|
| 88 | + | m.extend_from_slice(method.as_bytes()); |
|
| 89 | + | m.push(0); |
|
| 90 | + | m.extend_from_slice(body_hash); |
|
| 91 | + | m.extend_from_slice(&time_ms.to_be_bytes()); |
|
| 92 | + | m.extend_from_slice(nonce); |
|
| 93 | + | m |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | #[cfg(test)] |
|
| 97 | + | mod tests { |
|
| 98 | + | use super::*; |
|
| 99 | + | ||
| 100 | + | #[test] |
|
| 101 | + | fn a_proof_round_trips_and_binds_key_method_body_and_time() { |
|
| 102 | + | let a = KeyPair::generate("a", ""); |
|
| 103 | + | let b = KeyPair::generate("b", ""); |
|
| 104 | + | let p = Proof::sign(&a, "POST /mcp", b"{\"x\":1}"); |
|
| 105 | + | let q = Proof::decode(&p.encode()).unwrap(); |
|
| 106 | + | assert_eq!(p, q); |
|
| 107 | + | q.verify(&a.identity, Some(b"{\"x\":1}")).unwrap(); |
|
| 108 | + | assert!(q.verify(&a.identity, Some(b"{\"x\":2}")).is_err(), "another body"); |
|
| 109 | + | assert!(q.verify(&b.identity, None).is_err(), "another key"); |
|
| 110 | + | let mut r = q.clone(); |
|
| 111 | + | r.method = "POST /api".into(); |
|
| 112 | + | assert!(r.verify(&a.identity, None).is_err(), "another method"); |
|
| 113 | + | let mut t = q.clone(); |
|
| 114 | + | t.time_ms += 1; |
|
| 115 | + | assert!(t.verify(&a.identity, None).is_err(), "another time"); |
|
| 116 | + | } |
|
| 117 | + | } |
added crates/spectra-core/tests/principals.rs +224 −0
| 1 | + | //! Who may act as whom (spec §5.2 G1, G7; §8): an agent is its owner's |
|
| 2 | + | //! delegate or nothing, may not act in another's name, stops when its |
|
| 3 | + | //! delegation expires or is disabled, and a request is refused unless |
|
| 4 | + | //! signed, fresh, unreplayed and from the bound Unix user. |
|
| 5 | + | ||
| 6 | + | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | + | use oxy_core::identity::{Delegate, Identity, SecretKey}; |
|
| 8 | + | use spectra_core::authn::{Authenticator, Refusal}; |
|
| 9 | + | use spectra_core::event::NewEvent; |
|
| 10 | + | use spectra_core::ids::now_ms; |
|
| 11 | + | use spectra_core::keys::KeyPair; |
|
| 12 | + | use spectra_core::ledger::{Ledger, Problem}; |
|
| 13 | + | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE}; |
|
| 14 | + | use spectra_core::proof::Proof; |
|
| 15 | + | use std::path::Path; |
|
| 16 | + | ||
| 17 | + | /// What `oxy identity delegate add KEY --scope changes [--expires]` does to |
|
| 18 | + | /// the owner's home: a successor with the same key pair, its own secret |
|
| 19 | + | /// file, and `author.key` naming it. Only the owner's tools do this. |
|
| 20 | + | fn delegate(owner_home: &Path, agent: &Identity, expires: Option<u64>) -> KeyPair { |
|
| 21 | + | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 22 | + | let keys = owner_home.join("keys"); |
|
| 23 | + | let secret_file = std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap(); |
|
| 24 | + | let (_, secret) = SecretKey::decode(&secret_file).unwrap(); |
|
| 25 | + | let mut delegates = cur.identity.delegates.clone(); |
|
| 26 | + | delegates.retain(|d| d.key != agent.hash()); |
|
| 27 | + | delegates.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires }); |
|
| 28 | + | let next = cur.identity.successor(delegates, None, now_ms()); |
|
| 29 | + | let key = next.hash(); |
|
| 30 | + | write_atomic_private(&keys.join(format!("{}.secret", key.to_hex())), &secret.encode(&key)).unwrap(); |
|
| 31 | + | write_atomic(&keys.join(format!("{}.identity", key.to_hex())), &next.encode()).unwrap(); |
|
| 32 | + | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &key.to_hex()).unwrap(); |
|
| 33 | + | KeyPair::load_home(owner_home).unwrap() |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | struct World { |
|
| 37 | + | _dir: tempfile::TempDir, |
|
| 38 | + | owner_home: std::path::PathBuf, |
|
| 39 | + | ledger: Ledger, |
|
| 40 | + | owner: KeyPair, |
|
| 41 | + | } |
|
| 42 | + | ||
| 43 | + | async fn world() -> World { |
|
| 44 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 45 | + | let owner_home = dir.path().join("owner"); |
|
| 46 | + | let owner = KeyPair::create_home(&owner_home, "Andy McCall", "[email protected]").unwrap(); |
|
| 47 | + | let data = dir.path().join("data"); |
|
| 48 | + | std::fs::create_dir_all(&data).unwrap(); |
|
| 49 | + | let instance = KeyPair::instance(&data, "laptop").unwrap(); |
|
| 50 | + | let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap(); |
|
| 51 | + | ledger.append_one(signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity))).await.unwrap(); |
|
| 52 | + | ledger.append_one(signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))).await.unwrap(); |
|
| 53 | + | World { _dir: dir, owner_home, ledger, owner } |
|
| 54 | + | } |
|
| 55 | + | ||
| 56 | + | /// Add `name` as the owner would: delegate, record the successor, add. |
|
| 57 | + | async fn add_agent(w: &mut World, name: &str, expires: Option<u64>) -> KeyPair { |
|
| 58 | + | let agent = KeyPair::generate(name, ""); |
|
| 59 | + | let prev = w.owner.identity.clone(); |
|
| 60 | + | w.owner = delegate(&w.owner_home, &agent.identity, expires); |
|
| 61 | + | let mut rotated = identity_payload(&w.owner.identity); |
|
| 62 | + | rotated["chain"] = serde_json::json!([]); |
|
| 63 | + | assert_eq!(w.owner.identity.previous, Some(prev.hash())); |
|
| 64 | + | w.ledger.append_one(signed_event(&w.owner, "human", "identity.rotated", rotated)).await.unwrap(); |
|
| 65 | + | let mut p = identity_payload(&agent.identity); |
|
| 66 | + | p["name"] = name.into(); |
|
| 67 | + | p["kind"] = "claude-code".into(); |
|
| 68 | + | p["project"] = "oxygen".into(); |
|
| 69 | + | p["owner"] = w.owner.key.to_hex().into(); |
|
| 70 | + | p["owner_name"] = "Andy McCall".into(); |
|
| 71 | + | w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap(); |
|
| 72 | + | agent |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | fn said(agent: &KeyPair, actor: &str, body: &str) -> NewEvent { |
|
| 76 | + | signed_event(agent, actor, "message.posted", serde_json::json!({ "body": body })) |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | #[tokio::test] |
|
| 80 | + | async fn an_owned_agent_acts_and_the_ledger_verifies_every_signature() { |
|
| 81 | + | let mut w = world().await; |
|
| 82 | + | let a = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 83 | + | w.ledger.append_one(said(&a, "agent:claude-oxygen", "hello")).await.unwrap(); |
|
| 84 | + | w.ledger.checkpoint().await.unwrap(); |
|
| 85 | + | let mut p = Principals::default(); |
|
| 86 | + | let v = w.ledger.verify(&mut p).await.unwrap(); |
|
| 87 | + | assert!(v.clean(), "{:?}", v.findings); |
|
| 88 | + | let who = p.principal(&a.key, now_ms()).unwrap(); |
|
| 89 | + | assert_eq!(who.display(), "claude-oxygen · Andy McCall's agent"); |
|
| 90 | + | // The projection agrees, and so does a fresh load. |
|
| 91 | + | assert!(w.ledger.check().await.unwrap().is_empty()); |
|
| 92 | + | let loaded = Principals::load(&w.ledger).await.unwrap(); |
|
| 93 | + | assert!(matches!(loaded.principal(&a.key, now_ms()), Ok(Principal::Agent { .. }))); |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | #[tokio::test] |
|
| 97 | + | async fn an_agent_without_the_owners_delegation_cannot_be_added() { |
|
| 98 | + | let w = world().await; |
|
| 99 | + | let stray = KeyPair::generate("stray", ""); |
|
| 100 | + | let mut p = identity_payload(&stray.identity); |
|
| 101 | + | p["name"] = "stray".into(); |
|
| 102 | + | p["project"] = "oxygen".into(); |
|
| 103 | + | w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap(); |
|
| 104 | + | w.ledger.append_one(said(&stray, "agent:stray", "I am somebody's")).await.unwrap(); |
|
| 105 | + | let v = w.ledger.verify(&mut Principals::default()).await.unwrap(); |
|
| 106 | + | let bad: Vec<_> = v.findings.iter().filter(|f| f.problem == Problem::BadProof).collect(); |
|
| 107 | + | assert_eq!(bad.len(), 2, "the addition and the message: {:?}", v.findings); |
|
| 108 | + | assert!(bad[0].detail.contains("without the owner's delegation"), "{}", bad[0].detail); |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | #[tokio::test] |
|
| 112 | + | async fn an_agent_cannot_act_in_another_agents_name_or_the_owners() { |
|
| 113 | + | let mut w = world().await; |
|
| 114 | + | let a = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 115 | + | let _b = add_agent(&mut w, "antigravity-hub", None).await; |
|
| 116 | + | w.ledger.append_one(said(&a, "agent:antigravity-hub", "I am antigravity")).await.unwrap(); |
|
| 117 | + | w.ledger.append_one(said(&a, "human", "I am Andy")).await.unwrap(); |
|
| 118 | + | let v = w.ledger.verify(&mut Principals::default()).await.unwrap(); |
|
| 119 | + | assert_eq!(v.findings.len(), 2, "{:?}", v.findings); |
|
| 120 | + | assert!(v.findings.iter().all(|f| f.detail.contains("is signed by claude-oxygen"))); |
|
| 121 | + | } |
|
| 122 | + | ||
| 123 | + | #[tokio::test] |
|
| 124 | + | async fn an_agent_cannot_record_itself_as_the_owner_or_a_successor() { |
|
| 125 | + | let mut w = world().await; |
|
| 126 | + | let a = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 127 | + | // A successor of the owner that the agent made: it has no owner key to sign it with. |
|
| 128 | + | let forged = |
|
| 129 | + | w.owner.identity.successor(vec![Delegate { key: a.key, scope: oxy_core::identity::Scope::ALL, expires: None }], None, now_ms()); |
|
| 130 | + | let mut p = identity_payload(&forged); |
|
| 131 | + | p["chain"] = serde_json::json!([]); |
|
| 132 | + | w.ledger.append_one(signed_event(&a, "human", "identity.rotated", p)).await.unwrap(); |
|
| 133 | + | // And a second owner, signed by itself. |
|
| 134 | + | w.ledger.append_one(signed_event(&a, "human", "identity.owner", identity_payload(&a.identity))).await.unwrap(); |
|
| 135 | + | let v = w.ledger.verify(&mut Principals::default()).await.unwrap(); |
|
| 136 | + | assert_eq!(v.findings.len(), 2, "{:?}", v.findings); |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | #[tokio::test] |
|
| 140 | + | async fn an_expired_or_disabled_agent_is_refused_and_flagged() { |
|
| 141 | + | let mut w = world().await; |
|
| 142 | + | let a = add_agent(&mut w, "overnight", Some(now_ms() + 60_000)).await; |
|
| 143 | + | let principals = Principals::load(&w.ledger).await.unwrap(); |
|
| 144 | + | assert!(principals.principal(&a.key, now_ms()).is_ok()); |
|
| 145 | + | let later = now_ms() + 120_000; |
|
| 146 | + | let why = principals.principal(&a.key, later).unwrap_err(); |
|
| 147 | + | assert!(why.contains("expired"), "{why}"); |
|
| 148 | + | ||
| 149 | + | let b = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 150 | + | w.ledger.append_one(signed_event(&w.owner, "human", "agent.disabled", serde_json::json!({ "name": "claude-oxygen" }))).await.unwrap(); |
|
| 151 | + | w.ledger.append_one(said(&b, "agent:claude-oxygen", "still here?")).await.unwrap(); |
|
| 152 | + | let v = w.ledger.verify(&mut Principals::default()).await.unwrap(); |
|
| 153 | + | assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
|
| 154 | + | assert!(v.findings[0].detail.contains("disabled")); |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | #[tokio::test] |
|
| 158 | + | async fn a_proof_swapped_from_another_event_is_caught() { |
|
| 159 | + | let mut w = world().await; |
|
| 160 | + | let a = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 161 | + | let first = w.ledger.append_one(said(&a, "agent:claude-oxygen", "one")).await.unwrap(); |
|
| 162 | + | let second = w.ledger.append_one(said(&a, "agent:claude-oxygen", "two")).await.unwrap(); |
|
| 163 | + | let pool = w.ledger.pool().clone(); |
|
| 164 | + | sqlx::query("UPDATE events SET auth_proof = ? WHERE seq = ?") |
|
| 165 | + | .bind(first.auth.proof.clone()) |
|
| 166 | + | .bind(second.seq as i64) |
|
| 167 | + | .execute(&pool) |
|
| 168 | + | .await |
|
| 169 | + | .unwrap(); |
|
| 170 | + | let v = w.ledger.verify(&mut Principals::default()).await.unwrap(); |
|
| 171 | + | assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
|
| 172 | + | assert!(v.findings[0].detail.contains("different body")); |
|
| 173 | + | } |
|
| 174 | + | ||
| 175 | + | #[tokio::test] |
|
| 176 | + | async fn requests_are_refused_unless_signed_fresh_unreplayed_and_from_the_bound_uid() { |
|
| 177 | + | let mut w = world().await; |
|
| 178 | + | let a = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 179 | + | let principals = Principals::load(&w.ledger).await.unwrap(); |
|
| 180 | + | let auth = Authenticator::default(); |
|
| 181 | + | let now = now_ms(); |
|
| 182 | + | let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; |
|
| 183 | + | ||
| 184 | + | assert_eq!(auth.authenticate(&principals, None, "POST /mcp", body, now, Some(1000)).unwrap_err(), Refusal::Unsigned); |
|
| 185 | + | let good = Proof::sign(&a, "POST /mcp", body); |
|
| 186 | + | let caller = auth.authenticate(&principals, Some(&good), "POST /mcp", body, now, Some(1000)).unwrap(); |
|
| 187 | + | assert_eq!(caller.principal.actor(), "agent:claude-oxygen"); |
|
| 188 | + | assert_eq!(caller.auth.assurance, 2); |
|
| 189 | + | assert_eq!(auth.authenticate(&principals, Some(&good), "POST /mcp", body, now, Some(1000)).unwrap_err(), Refusal::Replay); |
|
| 190 | + | let other_body = Proof::sign(&a, "POST /mcp", body); |
|
| 191 | + | assert!(matches!(auth.authenticate(&principals, Some(&other_body), "POST /mcp", b"{}", now, None), Err(Refusal::BadSignature(_)))); |
|
| 192 | + | let old = Proof::sign_at(&a, "POST /mcp", body, now - 10 * 60_000, [7; 16]); |
|
| 193 | + | assert!(matches!(auth.authenticate(&principals, Some(&old), "POST /mcp", body, now, None), Err(Refusal::Stale(_)))); |
|
| 194 | + | let wrong = Proof::sign(&a, "POST /api/v1/channels", body); |
|
| 195 | + | assert!(matches!(auth.authenticate(&principals, Some(&wrong), "POST /mcp", body, now, None), Err(Refusal::WrongMethod(_)))); |
|
| 196 | + | let stranger = KeyPair::generate("stranger", ""); |
|
| 197 | + | assert!(matches!( |
|
| 198 | + | auth.authenticate(&principals, Some(&Proof::sign(&stranger, "POST /mcp", body)), "POST /mcp", body, now, None), |
|
| 199 | + | Err(Refusal::NotAPrincipal(_)) |
|
| 200 | + | )); |
|
| 201 | + | let owner = auth.authenticate(&principals, Some(&Proof::sign(&w.owner, "POST /mcp", body)), "POST /mcp", body, now, None).unwrap(); |
|
| 202 | + | assert_eq!(owner.principal.actor(), "human"); |
|
| 203 | + | ||
| 204 | + | // An agent bound to a Unix user, used from another. |
|
| 205 | + | let b = add_agent_bound(&mut w, "bound", 1001).await; |
|
| 206 | + | let principals = Principals::load(&w.ledger).await.unwrap(); |
|
| 207 | + | let r = auth.authenticate(&principals, Some(&Proof::sign(&b, "POST /mcp", body)), "POST /mcp", body, now, Some(1000)); |
|
| 208 | + | assert_eq!(r.unwrap_err(), Refusal::WrongUid { agent: "bound".into(), bound: 1001, from: 1000 }); |
|
| 209 | + | assert!(auth.authenticate(&principals, Some(&Proof::sign(&b, "POST /mcp", body)), "POST /mcp", body, now, Some(1001)).is_ok()); |
|
| 210 | + | } |
|
| 211 | + | ||
| 212 | + | async fn add_agent_bound(w: &mut World, name: &str, uid: u32) -> KeyPair { |
|
| 213 | + | let agent = KeyPair::generate(name, ""); |
|
| 214 | + | w.owner = delegate(&w.owner_home, &agent.identity, None); |
|
| 215 | + | let mut rotated = identity_payload(&w.owner.identity); |
|
| 216 | + | rotated["chain"] = serde_json::json!([]); |
|
| 217 | + | w.ledger.append_one(signed_event(&w.owner, "human", "identity.rotated", rotated)).await.unwrap(); |
|
| 218 | + | let mut p = identity_payload(&agent.identity); |
|
| 219 | + | p["name"] = name.into(); |
|
| 220 | + | p["project"] = "oxygen".into(); |
|
| 221 | + | p["uid"] = uid.into(); |
|
| 222 | + | w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap(); |
|
| 223 | + | agent |
|
| 224 | + | } |
added src/cli/agent.rs +166 −0
| 1 | + | //! `spectra agent`: agents as their owner's delegates (spec §8.1). |
|
| 2 | + | //! |
|
| 3 | + | //! `add` makes the agent's key pair in its own home, prints the one `oxy` |
|
| 4 | + | //! command that makes it the owner's, and records the agent only once the |
|
| 5 | + | //! owner's identity delegates to it. There is no agent without an owner. |
|
| 6 | + | ||
| 7 | + | use super::identity::{owner_home, refresh_owner}; |
|
| 8 | + | use super::{ui, Ctx}; |
|
| 9 | + | use anyhow::{bail, Context, Result}; |
|
| 10 | + | use spectra_core::ids::now_ms; |
|
| 11 | + | use spectra_core::keys::{home_identity, KeyPair}; |
|
| 12 | + | use spectra_core::ledger::Ledger; |
|
| 13 | + | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES, DELEGATION_SCOPE}; |
|
| 14 | + | use std::path::PathBuf; |
|
| 15 | + | use std::time::Duration; |
|
| 16 | + | ||
| 17 | + | pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"]; |
|
| 18 | + | ||
| 19 | + | pub struct Add { |
|
| 20 | + | pub name: String, |
|
| 21 | + | pub kind: String, |
|
| 22 | + | pub project: String, |
|
| 23 | + | pub home: PathBuf, |
|
| 24 | + | pub uid: Option<u32>, |
|
| 25 | + | pub learn_uid: bool, |
|
| 26 | + | pub wait_secs: u64, |
|
| 27 | + | pub owner_home: Option<PathBuf>, |
|
| 28 | + | } |
|
| 29 | + | ||
| 30 | + | pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> { |
|
| 31 | + | if !valid_name(&a.name) { |
|
| 32 | + | bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64"); |
|
| 33 | + | } |
|
| 34 | + | if !KINDS.contains(&a.kind.as_str()) { |
|
| 35 | + | bail!("--kind is one of {}", KINDS.join(", ")); |
|
| 36 | + | } |
|
| 37 | + | { |
|
| 38 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 39 | + | let p = Principals::load(&ledger).await?; |
|
| 40 | + | if p.agent_key(&a.name).is_some() { |
|
| 41 | + | bail!("an agent named {} already exists (spectra agent list)", a.name); |
|
| 42 | + | } |
|
| 43 | + | } |
|
| 44 | + | let home = owner_home(ctx, a.owner_home.clone()); |
|
| 45 | + | // The agent's key, made once in its own home and reused if `add` is run again. |
|
| 46 | + | let agent = if a.home.join("keys").exists() { |
|
| 47 | + | KeyPair::load_home(&a.home).with_context(|| format!("reading the agent's key in {}", a.home.display()))? |
|
| 48 | + | } else { |
|
| 49 | + | let pair = KeyPair::create_home(&a.home, &a.name, "")?; |
|
| 50 | + | println!("{} made {}'s key in {}", ui::done(ui::glyph("✔", "ok")), a.name, ui::path(a.home.display())); |
|
| 51 | + | pair |
|
| 52 | + | }; |
|
| 53 | + | let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms()); |
|
| 54 | + | if !delegated(&home_identity(&home)?) { |
|
| 55 | + | println!("To make {} yours, delegate to its key (as yourself):", a.name); |
|
| 56 | + | println!( |
|
| 57 | + | " {}", |
|
| 58 | + | ui::id(format!( |
|
| 59 | + | "oxy identity delegate add {} --scope changes --expires 8h", |
|
| 60 | + | a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display() |
|
| 61 | + | )) |
|
| 62 | + | ); |
|
| 63 | + | println!( |
|
| 64 | + | "{}", |
|
| 65 | + | ui::dim(format!("waiting up to {} s for the delegation; Ctrl-C stops, and running this again resumes", a.wait_secs)) |
|
| 66 | + | ); |
|
| 67 | + | let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs); |
|
| 68 | + | loop { |
|
| 69 | + | if home_identity(&home).is_ok_and(|id| delegated(&id)) { |
|
| 70 | + | break; |
|
| 71 | + | } |
|
| 72 | + | if std::time::Instant::now() >= deadline { |
|
| 73 | + | bail!("{}'s key is not delegated yet: run the command above, then this again", a.name); |
|
| 74 | + | } |
|
| 75 | + | tokio::time::sleep(Duration::from_secs(2)).await; |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | let ledger = ctx.writer().await?; |
|
| 79 | + | refresh_owner(&ledger, &home).await?; |
|
| 80 | + | let owner = KeyPair::load_home(&home)?; |
|
| 81 | + | let mut p = identity_payload(&agent.identity); |
|
| 82 | + | p["name"] = a.name.clone().into(); |
|
| 83 | + | p["kind"] = a.kind.into(); |
|
| 84 | + | p["project"] = a.project.into(); |
|
| 85 | + | p["home"] = a.home.display().to_string().into(); |
|
| 86 | + | p["owner"] = owner.key.to_hex().into(); |
|
| 87 | + | p["owner_name"] = owner.identity.name.clone().into(); |
|
| 88 | + | p["scopes"] = DEFAULT_SCOPES.into(); |
|
| 89 | + | if let Some(u) = a.uid { |
|
| 90 | + | p["uid"] = u.into(); |
|
| 91 | + | } |
|
| 92 | + | p["learn_uid"] = a.learn_uid.into(); |
|
| 93 | + | let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?; |
|
| 94 | + | p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires }); |
|
| 95 | + | ledger.append_one(signed_event(&owner, "human", "agent.added", p)).await?; |
|
| 96 | + | ledger.checkpoint().await?; |
|
| 97 | + | println!("{} {} · {}'s agent", ui::done(ui::glyph("✔", "ok")), a.name, owner.identity.name); |
|
| 98 | + | println!("Connect its client with {}.", ui::id(format!("spectra mcp --agent {}", a.name))); |
|
| 99 | + | Ok(0) |
|
| 100 | + | } |
|
| 101 | + | ||
| 102 | + | pub async fn list(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 103 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 104 | + | let p = Principals::load(&ledger).await?; |
|
| 105 | + | let rows: Vec<(String, String, String, String, Option<i64>)> = |
|
| 106 | + | sqlx::query_as("SELECT name, key, kind, project, disabled_ms FROM agents ORDER BY name").fetch_all(ledger.pool()).await?; |
|
| 107 | + | let owner = p.owner(); |
|
| 108 | + | let now = now_ms(); |
|
| 109 | + | let mut out = Vec::new(); |
|
| 110 | + | for (name, key, kind, project, disabled) in rows { |
|
| 111 | + | let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?; |
|
| 112 | + | let state = match p.principal(&k, now) { |
|
| 113 | + | Ok(_) => "ok".to_string(), |
|
| 114 | + | Err(why) => why, |
|
| 115 | + | }; |
|
| 116 | + | let expires = owner.and_then(|o| o.delegate(&k)).and_then(|d| d.expires); |
|
| 117 | + | let who = match p.principal(&k, now) { |
|
| 118 | + | Ok(pr @ Principal::Agent { .. }) => pr.display(), |
|
| 119 | + | _ => format!("{name} · {}'s agent", owner.map(|o| o.name.as_str()).unwrap_or("?")), |
|
| 120 | + | }; |
|
| 121 | + | out.push(serde_json::json!({ "name": name, "display": who, "key": key, "kind": kind, "project": project, "state": state, "delegation_expires": expires, "disabled": disabled.is_some() })); |
|
| 122 | + | } |
|
| 123 | + | if json { |
|
| 124 | + | println!("{}", serde_json::Value::Array(out)); |
|
| 125 | + | return Ok(0); |
|
| 126 | + | } |
|
| 127 | + | if out.is_empty() { |
|
| 128 | + | println!("No agents yet: {}", ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR")); |
|
| 129 | + | } |
|
| 130 | + | for a in out { |
|
| 131 | + | let state = a["state"].as_str().unwrap_or(""); |
|
| 132 | + | let mark = if state == "ok" { ui::ok(ui::glyph("●", "*")) } else { ui::warn(ui::glyph("○", "o")) }; |
|
| 133 | + | let until = a["delegation_expires"].as_u64().map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default(); |
|
| 134 | + | println!( |
|
| 135 | + | "{mark} {} {} {} {}{until}", |
|
| 136 | + | a["display"].as_str().unwrap_or(""), |
|
| 137 | + | a["kind"].as_str().unwrap_or(""), |
|
| 138 | + | a["project"].as_str().unwrap_or(""), |
|
| 139 | + | ui::hash(&a["key"].as_str().unwrap_or("")[..12]) |
|
| 140 | + | ); |
|
| 141 | + | if state != "ok" { |
|
| 142 | + | println!(" {}", ui::warn(state)); |
|
| 143 | + | } |
|
| 144 | + | } |
|
| 145 | + | Ok(0) |
|
| 146 | + | } |
|
| 147 | + | ||
| 148 | + | pub async fn disable(ctx: &Ctx, name: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 149 | + | let ledger = ctx.writer().await?; |
|
| 150 | + | let p = Principals::load(&ledger).await?; |
|
| 151 | + | p.agent_key(name).with_context(|| format!("no agent named {name}"))?; |
|
| 152 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 153 | + | refresh_owner(&ledger, &home).await?; |
|
| 154 | + | let owner = KeyPair::load_home(&home)?; |
|
| 155 | + | ledger.append_one(signed_event(&owner, "human", "agent.disabled", serde_json::json!({ "name": name }))).await?; |
|
| 156 | + | ledger.checkpoint().await?; |
|
| 157 | + | println!("{} {name} is disabled; its key is refused from now on", ui::done(ui::glyph("✔", "ok"))); |
|
| 158 | + | Ok(0) |
|
| 159 | + | } |
|
| 160 | + | ||
| 161 | + | pub fn valid_name(n: &str) -> bool { |
|
| 162 | + | !n.is_empty() |
|
| 163 | + | && n.len() <= 64 |
|
| 164 | + | && n.bytes().next().is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()) |
|
| 165 | + | && n.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') |
|
| 166 | + | } |
added src/cli/identity.rs +145 −0
| 1 | + | //! `spectra init` and `spectra identity`: the owner and the instance |
|
| 2 | + | //! (spec §8.3, §9.5). The owner proves possession of their identity by |
|
| 3 | + | //! signing with it; Spectra never holds the owner's secret key. |
|
| 4 | + | ||
| 5 | + | use super::{ui, Ctx}; |
|
| 6 | + | use anyhow::{bail, Context, Result}; |
|
| 7 | + | use oxy_core::identity::Identity; |
|
| 8 | + | use spectra_core::keys::KeyPair; |
|
| 9 | + | use spectra_core::ledger::Ledger; |
|
| 10 | + | use spectra_core::principals::{identity_payload, signed_event, Principals}; |
|
| 11 | + | use std::path::{Path, PathBuf}; |
|
| 12 | + | ||
| 13 | + | /// The owner's `OXY_HOME`: `--owner-home`, else `[owner] home` in |
|
| 14 | + | /// spectra.toml, else oxy's own default. |
|
| 15 | + | pub fn owner_home(ctx: &Ctx, flag: Option<PathBuf>) -> PathBuf { |
|
| 16 | + | flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(oxy_core::repo::default_home) |
|
| 17 | + | } |
|
| 18 | + | ||
| 19 | + | pub async fn init(ctx: &Ctx, owner_home_flag: Option<PathBuf>, name: Option<String>) -> Result<i32> { |
|
| 20 | + | let data = &ctx.data; |
|
| 21 | + | let db = data.join("spectra.db"); |
|
| 22 | + | if db.exists() { |
|
| 23 | + | bail!("{} already holds a Spectra (spectra identity show)", data.display()); |
|
| 24 | + | } |
|
| 25 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 26 | + | let owner = KeyPair::load_home(&home).with_context(|| { |
|
| 27 | + | format!("the owner signs with their own identity: none usable in {} (oxy identity create, or --owner-home)", home.display()) |
|
| 28 | + | })?; |
|
| 29 | + | std::fs::create_dir_all(data)?; |
|
| 30 | + | #[cfg(unix)] |
|
| 31 | + | { |
|
| 32 | + | use std::os::unix::fs::PermissionsExt; |
|
| 33 | + | std::fs::set_permissions(data, std::fs::Permissions::from_mode(0o700))?; |
|
| 34 | + | } |
|
| 35 | + | let name = name.unwrap_or_else(hostname); |
|
| 36 | + | let instance = KeyPair::instance(data, &name)?; |
|
| 37 | + | let ledger = Ledger::open(&db, Some(instance.clone())).await?; |
|
| 38 | + | ledger |
|
| 39 | + | .append(vec![ |
|
| 40 | + | signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)), |
|
| 41 | + | signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity)), |
|
| 42 | + | ]) |
|
| 43 | + | .await?; |
|
| 44 | + | ledger.checkpoint().await?; |
|
| 45 | + | let home_s = home.display().to_string(); |
|
| 46 | + | std::fs::write( |
|
| 47 | + | data.join("spectra.toml"), |
|
| 48 | + | format!("# Spectra's configuration (spec §22.1).\n\n[owner]\nkey = \"{}\"\nhome = {}\n", owner.key.to_hex(), toml_str(&home_s)), |
|
| 49 | + | )?; |
|
| 50 | + | println!("{} Spectra initialised in {}", ui::done(ui::glyph("✔", "ok")), ui::path(data.display())); |
|
| 51 | + | println!("{}", ui::kv("owner", format!("{} {}", owner.identity.name, ui::hash(owner.key.short(12))))); |
|
| 52 | + | println!("{}", ui::kv("instance", format!("{} {}", name, ui::hash(instance.key.short(12))))); |
|
| 53 | + | println!(); |
|
| 54 | + | println!("So that checkpoints are verifiably from your machine, delegate to the instance key:"); |
|
| 55 | + | println!( |
|
| 56 | + | " {}", |
|
| 57 | + | ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display())) |
|
| 58 | + | ); |
|
| 59 | + | println!("Then add agents with {}.", ui::id("spectra agent add NAME --kind KIND --project PROJECT --home DIR")); |
|
| 60 | + | Ok(0) |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | pub async fn show(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 64 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 65 | + | let p = Principals::load(&ledger).await?; |
|
| 66 | + | let owner = p.owner().context("no owner is recorded")?; |
|
| 67 | + | let instance = p.instance(); |
|
| 68 | + | let delegated = |
|
| 69 | + | instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms())); |
|
| 70 | + | if json { |
|
| 71 | + | println!( |
|
| 72 | + | "{}", |
|
| 73 | + | serde_json::json!({ |
|
| 74 | + | "owner": { "name": owner.name, "key": owner.hash().to_hex() }, |
|
| 75 | + | "instance": instance.map(|i| serde_json::json!({ "name": i.name, "key": i.hash().to_hex(), "delegated": delegated })), |
|
| 76 | + | }) |
|
| 77 | + | ); |
|
| 78 | + | return Ok(0); |
|
| 79 | + | } |
|
| 80 | + | println!("{}", ui::kv("owner", format!("{} <{}> {}", owner.name, owner.email, ui::hash(owner.hash().to_hex())))); |
|
| 81 | + | if let Some(i) = instance { |
|
| 82 | + | let note = if delegated { ui::ok("delegated by the owner") } else { ui::warn("not delegated by the owner yet") }; |
|
| 83 | + | println!("{}", ui::kv("instance", format!("{} {} {}", i.name, ui::hash(i.hash().short(12)), note))); |
|
| 84 | + | } |
|
| 85 | + | Ok(0) |
|
| 86 | + | } |
|
| 87 | + | ||
| 88 | + | /// Record the owner's current identity when it has moved on (a delegation |
|
| 89 | + | /// added or removed): `spectra identity refresh`. |
|
| 90 | + | pub async fn refresh(ctx: &Ctx, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 91 | + | let ledger = ctx.writer().await?; |
|
| 92 | + | let outcome = refresh_owner(&ledger, &owner_home(ctx, owner_home_flag)).await?; |
|
| 93 | + | ledger.checkpoint().await?; |
|
| 94 | + | match outcome { |
|
| 95 | + | Some(id) => println!("{} recorded the owner's current identity {}", ui::done(ui::glyph("✔", "ok")), ui::hash(id.hash().short(12))), |
|
| 96 | + | None => println!("{} the owner's identity is current", ui::done(ui::glyph("✔", "ok"))), |
|
| 97 | + | } |
|
| 98 | + | Ok(0) |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | /// If the owner's home names a successor of the recorded owner, record it, |
|
| 102 | + | /// signed with it, with the identities between. Returns the new identity. |
|
| 103 | + | pub async fn refresh_owner(ledger: &Ledger, home: &Path) -> Result<Option<Identity>> { |
|
| 104 | + | let p = Principals::load(ledger).await?; |
|
| 105 | + | let recorded = p.owner().context("no owner is recorded (spectra init)")?.clone(); |
|
| 106 | + | let pair = KeyPair::load_home(home)?; |
|
| 107 | + | if pair.key == recorded.hash() { |
|
| 108 | + | return Ok(None); |
|
| 109 | + | } |
|
| 110 | + | let mut chain = Vec::new(); |
|
| 111 | + | let mut at = pair.identity.clone(); |
|
| 112 | + | while let Some(prev) = at.previous { |
|
| 113 | + | if prev == recorded.hash() { |
|
| 114 | + | break; |
|
| 115 | + | } |
|
| 116 | + | let file = home.join("keys").join(format!("{}.identity", prev.to_hex())); |
|
| 117 | + | at = Identity::decode( |
|
| 118 | + | &std::fs::read(&file).with_context(|| format!("{} is not a successor of the recorded owner", pair.identity.name))?, |
|
| 119 | + | ) |
|
| 120 | + | .map_err(|e| anyhow::anyhow!("{}: {e}", file.display()))?; |
|
| 121 | + | chain.push(hex::encode(at.encode())); |
|
| 122 | + | if chain.len() > 1000 { |
|
| 123 | + | bail!("the owner's identity chain is too long to follow"); |
|
| 124 | + | } |
|
| 125 | + | } |
|
| 126 | + | if at.previous != Some(recorded.hash()) && pair.identity.previous != Some(recorded.hash()) { |
|
| 127 | + | bail!("{} in {} is not a successor of the recorded owner {}", pair.identity.name, home.display(), recorded.hash().short(12)); |
|
| 128 | + | } |
|
| 129 | + | let mut payload = identity_payload(&pair.identity); |
|
| 130 | + | payload["chain"] = chain.into(); |
|
| 131 | + | ledger.append_one(signed_event(&pair, "human", "identity.rotated", payload)).await?; |
|
| 132 | + | Ok(Some(pair.identity)) |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | fn hostname() -> String { |
|
| 136 | + | std::fs::read_to_string("/etc/hostname") |
|
| 137 | + | .ok() |
|
| 138 | + | .map(|h| h.trim().to_string()) |
|
| 139 | + | .filter(|h| !h.is_empty()) |
|
| 140 | + | .unwrap_or_else(|| "spectra".into()) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | fn toml_str(s: &str) -> String { |
|
| 144 | + | serde_json::to_string(s).unwrap_or_default() |
|
| 145 | + | } |
added tests/cli_identity.rs +109 −0
| 1 | + | //! The owner and agents from the command line (spec §8.1, §8.3), as the |
|
| 2 | + | //! built binary in a hermetic environment: `init` proves the owner's |
|
| 3 | + | //! identity, `agent add` refuses until the owner delegates, ownership is |
|
| 4 | + | //! shown, and the ledger verifies throughout. |
|
| 5 | + | ||
| 6 | + | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | + | use oxy_core::identity::{Delegate, Identity, SecretKey}; |
|
| 8 | + | use spectra_core::keys::KeyPair; |
|
| 9 | + | use spectra_core::principals::DELEGATION_SCOPE; |
|
| 10 | + | use std::path::Path; |
|
| 11 | + | use std::process::{Command, Output}; |
|
| 12 | + | ||
| 13 | + | fn hermetic(dir: &Path) -> Command { |
|
| 14 | + | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 15 | + | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 16 | + | .env("OXY_HOME", dir.join("owner")) |
|
| 17 | + | .env("HOME", dir) |
|
| 18 | + | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 19 | + | .env("NO_COLOR", "1"); |
|
| 20 | + | c |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | fn run(dir: &Path, args: &[&str]) -> Output { |
|
| 24 | + | hermetic(dir).args(args).output().unwrap() |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | fn text(o: &Output) -> String { |
|
| 28 | + | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | /// `oxy identity delegate add IDENTITY --scope changes` in the owner's home. |
|
| 32 | + | fn delegate(owner_home: &Path, agent: &Identity) { |
|
| 33 | + | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 34 | + | let keys = owner_home.join("keys"); |
|
| 35 | + | let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap(); |
|
| 36 | + | let mut d = cur.identity.delegates.clone(); |
|
| 37 | + | d.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires: None }); |
|
| 38 | + | let next = cur.identity.successor(d, None, spectra_core::ids::now_ms()); |
|
| 39 | + | let k = next.hash(); |
|
| 40 | + | write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); |
|
| 41 | + | write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); |
|
| 42 | + | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); |
|
| 43 | + | } |
|
| 44 | + | ||
| 45 | + | fn verify_clean(dir: &Path) { |
|
| 46 | + | let o = run(dir, &["admin", "verify", "--json"]); |
|
| 47 | + | let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("{}", text(&o))); |
|
| 48 | + | assert!(o.status.success() && v["findings"].as_array().unwrap().is_empty(), "{v}"); |
|
| 49 | + | } |
|
| 50 | + | ||
| 51 | + | #[test] |
|
| 52 | + | fn init_then_an_owned_agent_end_to_end() { |
|
| 53 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 54 | + | let d = dir.path(); |
|
| 55 | + | KeyPair::create_home(&d.join("owner"), "Andy McCall", "[email protected]").unwrap(); |
|
| 56 | + | ||
| 57 | + | let o = run(d, &["init", "--name", "laptop"]); |
|
| 58 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 59 | + | assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o)); |
|
| 60 | + | assert!(!run(d, &["init"]).status.success(), "a second init is refused"); |
|
| 61 | + | verify_clean(d); |
|
| 62 | + | ||
| 63 | + | let home = d.join("agents/claude-oxygen"); |
|
| 64 | + | let home_s = home.to_str().unwrap(); |
|
| 65 | + | let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--wait", "0"]; |
|
| 66 | + | let o = run(d, &args); |
|
| 67 | + | assert!(!o.status.success(), "refused until delegated"); |
|
| 68 | + | assert!(text(&o).contains("oxy identity delegate add") && text(&o).contains("not delegated yet"), "{}", text(&o)); |
|
| 69 | + | let agent = KeyPair::load_home(&home).unwrap(); |
|
| 70 | + | ||
| 71 | + | delegate(&d.join("owner"), &agent.identity); |
|
| 72 | + | let o = run(d, &args); |
|
| 73 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 74 | + | assert!(text(&o).contains("claude-oxygen · Andy McCall's agent"), "{}", text(&o)); |
|
| 75 | + | assert_eq!(KeyPair::load_home(&home).unwrap().key, agent.key, "the key made first is the key recorded"); |
|
| 76 | + | ||
| 77 | + | let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap(); |
|
| 78 | + | assert_eq!(list[0]["display"], "claude-oxygen · Andy McCall's agent"); |
|
| 79 | + | assert_eq!(list[0]["state"], "ok"); |
|
| 80 | + | verify_clean(d); |
|
| 81 | + | ||
| 82 | + | assert!(run(d, &["agent", "disable", "claude-oxygen"]).status.success()); |
|
| 83 | + | let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap(); |
|
| 84 | + | assert!(list[0]["state"].as_str().unwrap().contains("disabled"), "{list}"); |
|
| 85 | + | verify_clean(d); |
|
| 86 | + | assert!(run(d, &["admin", "check"]).status.success()); |
|
| 87 | + | } |
|
| 88 | + | ||
| 89 | + | #[test] |
|
| 90 | + | fn an_owner_without_a_secret_key_cannot_init() { |
|
| 91 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 92 | + | let o = run(dir.path(), &["init"]); |
|
| 93 | + | assert!(!o.status.success()); |
|
| 94 | + | assert!(text(&o).contains("the owner signs with their own identity"), "{}", text(&o)); |
|
| 95 | + | } |
|
| 96 | + | ||
| 97 | + | #[tokio::test] |
|
| 98 | + | async fn a_second_writer_is_refused() { |
|
| 99 | + | let dir = tempfile::tempdir().unwrap(); |
|
| 100 | + | let d = dir.path(); |
|
| 101 | + | KeyPair::create_home(&d.join("owner"), "Andy McCall", "").unwrap(); |
|
| 102 | + | assert!(run(d, &["init"]).status.success()); |
|
| 103 | + | let instance = KeyPair::instance(&d.join("data"), "x").unwrap(); |
|
| 104 | + | let held = spectra_core::ledger::Ledger::open(&d.join("data/spectra.db"), Some(instance)).await.unwrap(); |
|
| 105 | + | let o = run(d, &["agent", "disable", "nobody"]); |
|
| 106 | + | assert!(text(&o).contains("another process is writing"), "{}", text(&o)); |
|
| 107 | + | assert!(run(d, &["admin", "verify"]).status.success(), "reading needs no lock"); |
|
| 108 | + | drop(held); |
|
| 109 | + | } |
modified CHANGELOG.md +1 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Added: identities instead of tokens (#4).** `spectra init` records the owner's Oxygen identity, proven by signing with it, and makes the instance key. `spectra agent add` makes an agent's key pair in its own home and records the agent only once the owner's identity delegates to it (`oxy identity delegate add`), so every agent is somebody's: `claude-oxygen · Andy McCall's agent`. Expiry and `spectra agent disable` stop an agent's key. `spectra admin verify` replays identities and delegations and checks every signed event, refusing an event whose actor is not its signer. Requests are authenticated by signature, a five-minute window, single-use nonces and an optional Unix-user binding. One process writes a ledger at a time. |
|
| 5 | 6 | - **Added: the ledger (#3).** Every event is deterministic CBOR (through `oxy-core`) hashed with a domain-separated BLAKE3 and chained to the one before; the envelope, with its hybrid logical clock, is frozen. The instance key signs a checkpoint every 256 events, every ten minutes while writing, and on demand. `spectra admin verify [--json]` recomputes the chain and checks every checkpoint, catching an edited, removed or re-signed event; `spectra admin check` and `rebuild` keep projections equal to the ledger. A single append takes 0.15 ms. |
|
| 6 | 7 | - **Added: CI on OxygenFlow (#2).** `.flow/pipeline.kdl` in the shape of Oxygen's: fmt, clippy, test (nextest under llvm-cov, with JUnit and coverage for the hub) and build, in the vendored image with no network. |
|
| 7 | 8 | - **Added: the project skeleton (#1).** A Cargo workspace in Oxygen's shape (`spectra-core`, `spectra-mcp`, `spectra-api`, `radar` and the `spectra` binary), Oxygen pinned under `deps/oxygen` for identities and signatures, `make check`, the changelog check, `spectra version [--json]` with the build identity, and `scripts/deps-check.sh` to keep Spectra buildable by the vendored OxygenFlow image. |
modified Cargo.lock +1 −0
| … | 1912 unchanged lines |
||
| 1913 | 1913 | "sqlx", |
|
| 1914 | 1914 | "tempfile", |
|
| 1915 | 1915 | "tokio", |
|
| 1916 | + | "toml", |
|
| 1916 | 1917 | ] |
|
| 1917 | 1918 | ||
| 1918 | 1919 | [[package]] |
|
| … | 1057 unchanged lines |
modified Cargo.toml +3 −0
| … | 67 unchanged lines |
||
| 68 | 68 | serde_json.workspace = true |
|
| 69 | 69 | tokio.workspace = true |
|
| 70 | 70 | hex.workspace = true |
|
| 71 | + | toml.workspace = true |
|
| 72 | + | sqlx.workspace = true |
|
| 71 | 73 | console = "0.15" |
|
| 72 | 74 | indicatif = "0.17" |
|
| 73 | 75 | libc = "0.2" |
|
| … | 2 unchanged lines |
||
| 76 | 78 | [dev-dependencies] |
|
| 77 | 79 | tempfile.workspace = true |
|
| 78 | 80 | sqlx.workspace = true |
|
| 81 | + | serde_json.workspace = true |
|
| 79 | 82 | ||
| 80 | 83 | [profile.release] |
|
| 81 | 84 | debug = 1 |
modified crates/spectra-core/src/ledger.rs +54 −9
| … | 28 unchanged lines |
||
| 29 | 29 | /// The ledger: the database, the chain head, the clock and the bus. |
|
| 30 | 30 | pub struct Ledger { |
|
| 31 | 31 | pool: SqlitePool, |
|
| 32 | + | /// Held for as long as this ledger may append: one writer per data |
|
| 33 | + | /// directory, so the chain never forks (`None` when opened read-only). |
|
| 34 | + | _lock: Option<std::fs::File>, |
|
| 32 | 35 | head: Mutex<Head>, |
|
| 33 | 36 | clock: Clock, |
|
| 34 | 37 | bus: broadcast::Sender<Arc<Event>>, |
|
| … | 13 unchanged lines |
||
| 48 | 51 | /// written by a newer format. Checkpoints are signed with `instance`; |
|
| 49 | 52 | /// without one (tests, read-only tools) none are made. |
|
| 50 | 53 | pub async fn open(path: &Path, instance: Option<KeyPair>) -> Result<Ledger> { |
|
| 54 | + | let lock_path = path.with_extension("lock"); |
|
| 55 | + | let lock = std::fs::OpenOptions::new() |
|
| 56 | + | .create(true) |
|
| 57 | + | .truncate(false) |
|
| 58 | + | .write(true) |
|
| 59 | + | .open(&lock_path) |
|
| 60 | + | .with_context(|| format!("opening {}", lock_path.display()))?; |
|
| 61 | + | match lock.try_lock() { |
|
| 62 | + | Ok(()) => {} |
|
| 63 | + | Err(std::fs::TryLockError::WouldBlock) => { |
|
| 64 | + | bail!("another process is writing to {} (is `spectra serve` running? stop it, or do this through it)", path.display()) |
|
| 65 | + | } |
|
| 66 | + | Err(std::fs::TryLockError::Error(e)) => return Err(e).with_context(|| format!("locking {}", lock_path.display())), |
|
| 67 | + | } |
|
| 68 | + | let mut l = Ledger::open_unlocked(path, instance).await?; |
|
| 69 | + | l._lock = Some(lock); |
|
| 70 | + | Ok(l) |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | /// Open for reading and verifying only, while another process may be |
|
| 74 | + | /// writing: no lock, and `append` must not be called. |
|
| 75 | + | pub async fn open_read_only(path: &Path) -> Result<Ledger> { |
|
| 76 | + | Ledger::open_unlocked(path, None).await |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | async fn open_unlocked(path: &Path, instance: Option<KeyPair>) -> Result<Ledger> { |
|
| 51 | 80 | let opts = SqliteConnectOptions::new() |
|
| 52 | 81 | .filename(path) |
|
| 53 | 82 | .create_if_missing(true) |
|
| … | 33 unchanged lines |
||
| 87 | 116 | let (bus, _) = broadcast::channel(4096); |
|
| 88 | 117 | Ok(Ledger { |
|
| 89 | 118 | pool, |
|
| 119 | + | _lock: None, |
|
| 90 | 120 | head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }), |
|
| 91 | 121 | clock: Clock::starting_at(hlc), |
|
| 92 | 122 | bus, |
|
| … | 142 unchanged lines |
||
| 235 | 265 | rows.iter().map(row_to_event).collect() |
|
| 236 | 266 | } |
|
| 237 | 267 | ||
| 268 | + | /// Events of the given kinds from `from` (inclusive), oldest first. |
|
| 269 | + | pub async fn events_of_kinds(&self, from: u64, kinds: &[&str], limit: u32) -> Result<Vec<Event>> { |
|
| 270 | + | let marks = vec!["?"; kinds.len()].join(","); |
|
| 271 | + | let sql = format!("{SELECT_EVENT} WHERE seq >= ? AND kind IN ({marks}) ORDER BY seq LIMIT ?"); |
|
| 272 | + | let mut q = sqlx::query(&sql).bind(from as i64); |
|
| 273 | + | for k in kinds { |
|
| 274 | + | q = q.bind(*k); |
|
| 275 | + | } |
|
| 276 | + | let rows = q.bind(limit as i64).fetch_all(&self.pool).await?; |
|
| 277 | + | rows.iter().map(row_to_event).collect() |
|
| 278 | + | } |
|
| 279 | + | ||
| 238 | 280 | /// Recompute the chain and check every checkpoint and every proof it |
|
| 239 | 281 | /// can, from the ledger alone (spec §9.5, `spectra admin verify`). |
|
| 240 | - | pub async fn verify(&self, keys: &dyn KeyResolver) -> Result<Verification> { |
|
| 282 | + | pub async fn verify(&self, keys: &mut dyn KeyResolver) -> Result<Verification> { |
|
| 241 | 283 | let mut v = Verification::default(); |
|
| 242 | 284 | let mut prev = [0u8; 32]; |
|
| 243 | 285 | let mut expect = 1u64; |
|
| … | 29 unchanged lines |
||
| 273 | 315 | match check_checkpoint(e, prev, instance.as_ref()) { |
|
| 274 | 316 | Ok(()) => v.last_checkpoint = Some(e.seq), |
|
| 275 | 317 | Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why), |
|
| 276 | - | } |
|
| 277 | - | } else if e.auth.method == Method::Signature || e.auth.method == Method::Passkey { |
|
| 278 | - | if let Err(why) = keys.check(e) { |
|
| 279 | - | v.finding(e.seq, Problem::BadProof, why); |
|
| 280 | 318 | } |
|
| 319 | + | } else if let Err(why) = keys.check(e) { |
|
| 320 | + | v.finding(e.seq, Problem::BadProof, why); |
|
| 281 | 321 | } |
|
| 282 | 322 | prev = e.hash; |
|
| 283 | 323 | expect += 1; |
|
| … | 63 unchanged lines |
||
| 347 | 387 | ||
| 348 | 388 | /// Checks the proof of a signed event: the identities and delegations that |
|
| 349 | 389 | /// say whose key it was (spec §8.5). Principals supply it (`principals`). |
|
| 350 | - | pub trait KeyResolver: Send + Sync { |
|
| 351 | - | fn check(&self, e: &Event) -> std::result::Result<(), String>; |
|
| 390 | + | /// It is given every event but checkpoints, in order, so it can follow |
|
| 391 | + | /// identities as they are recorded. |
|
| 392 | + | pub trait KeyResolver: Send { |
|
| 393 | + | fn check(&mut self, e: &Event) -> std::result::Result<(), String>; |
|
| 352 | 394 | } |
|
| 353 | 395 | ||
| 354 | 396 | /// A resolver that knows no keys: every signed event is reported. |
|
| 355 | 397 | pub struct NoKeys; |
|
| 356 | 398 | impl KeyResolver for NoKeys { |
|
| 357 | - | fn check(&self, _: &Event) -> std::result::Result<(), String> { |
|
| 358 | - | Err("no identity is known for its key".into()) |
|
| 399 | + | fn check(&mut self, e: &Event) -> std::result::Result<(), String> { |
|
| 400 | + | match e.auth.method { |
|
| 401 | + | Method::Signature | Method::Passkey => Err("no identity is known for its key".into()), |
|
| 402 | + | _ => Ok(()), |
|
| 403 | + | } |
|
| 359 | 404 | } |
|
| 360 | 405 | } |
|
| 361 | 406 | ||
| … | 145 unchanged lines |
modified crates/spectra-core/src/lib.rs +3 −0
| … | 4 unchanged lines |
||
| 5 | 5 | //! Identities, delegation, composite signatures, deterministic CBOR and |
|
| 6 | 6 | //! hashing come from `oxy-core`; nothing here reimplements them. |
|
| 7 | 7 | ||
| 8 | + | pub mod authn; |
|
| 8 | 9 | pub mod event; |
|
| 9 | 10 | pub mod hlc; |
|
| 10 | 11 | pub mod ids; |
|
| 11 | 12 | pub mod keys; |
|
| 12 | 13 | pub mod ledger; |
|
| 14 | + | pub mod principals; |
|
| 13 | 15 | pub mod projections; |
|
| 16 | + | pub mod proof; |
|
| 14 | 17 | ||
| 15 | 18 | /// The ledger's format version: the frozen event envelope (spec §9.1). A |
|
| 16 | 19 | /// database written with a newer format is refused, never guessed at. |
|
| … | 1 unchanged lines |
modified crates/spectra-core/src/projections.rs +2 −1
| … | 6 unchanged lines |
||
| 7 | 7 | use sqlx::SqliteConnection; |
|
| 8 | 8 | ||
| 9 | 9 | /// The projection tables, in the order they are cleared and compared. |
|
| 10 | - | pub const TABLES: &[&str] = &["event_counts"]; |
|
| 10 | + | pub const TABLES: &[&str] = &["event_counts", "identities", "agents"]; |
|
| 11 | 11 | ||
| 12 | 12 | /// Apply one event to every projection. |
|
| 13 | 13 | pub async fn apply(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
|
| … | 1 unchanged lines |
||
| 15 | 15 | .bind(&e.kind) |
|
| 16 | 16 | .execute(&mut *conn) |
|
| 17 | 17 | .await?; |
|
| 18 | + | crate::principals::project(conn, e).await?; |
|
| 18 | 19 | Ok(()) |
|
| 19 | 20 | } |
modified crates/spectra-core/tests/ledger.rs +7 −7
| … | 41 unchanged lines |
||
| 42 | 42 | } |
|
| 43 | 43 | let cp = ledger.checkpoint().await.unwrap().expect("events since the last checkpoint"); |
|
| 44 | 44 | assert_eq!(cp.kind, "ledger.checkpoint"); |
|
| 45 | - | let v = ledger.verify(&NoKeys).await.unwrap(); |
|
| 45 | + | let v = ledger.verify(&mut NoKeys).await.unwrap(); |
|
| 46 | 46 | assert!(v.clean(), "{:?}", v.findings); |
|
| 47 | 47 | assert_eq!(v.checkpoints, 2, "one at 256 events, one on demand"); |
|
| 48 | 48 | assert_eq!(v.uncovered, 0); |
|
| … | 11 unchanged lines |
||
| 60 | 60 | ledger.checkpoint().await.unwrap(); |
|
| 61 | 61 | drop(ledger); |
|
| 62 | 62 | raw(dir.path(), r#"UPDATE events SET payload = CAST('{"body":"message 5, edited","n":5}' AS BLOB) WHERE subject = 'm5'"#).await; |
|
| 63 | - | let v = open(dir.path()).await.verify(&NoKeys).await.unwrap(); |
|
| 63 | + | let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
|
| 64 | 64 | assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
|
| 65 | 65 | assert_eq!(v.findings[0].problem, Problem::HashMismatch); |
|
| 66 | 66 | } |
|
| … | 17 unchanged lines |
||
| 84 | 84 | &format!(r#"UPDATE events SET payload = CAST('{{"body":"forged","n":19}}' AS BLOB), hash = X'{forged}' WHERE seq = {}"#, e.seq), |
|
| 85 | 85 | ) |
|
| 86 | 86 | .await; |
|
| 87 | - | let v = open(dir.path()).await.verify(&NoKeys).await.unwrap(); |
|
| 87 | + | let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
|
| 88 | 88 | assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak || f.problem == Problem::BadCheckpoint), "{:?}", v.findings); |
|
| 89 | 89 | } |
|
| 90 | 90 | ||
| … | 6 unchanged lines |
||
| 97 | 97 | } |
|
| 98 | 98 | drop(ledger); |
|
| 99 | 99 | raw(dir.path(), "DELETE FROM events WHERE subject = 'm4'").await; |
|
| 100 | - | let v = open(dir.path()).await.verify(&NoKeys).await.unwrap(); |
|
| 100 | + | let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
|
| 101 | 101 | assert!(v.findings.iter().any(|f| f.problem == Problem::Gap), "{:?}", v.findings); |
|
| 102 | 102 | assert!(v.findings.iter().any(|f| f.problem == Problem::ChainBreak), "{:?}", v.findings); |
|
| 103 | 103 | } |
|
| … | 9 unchanged lines |
||
| 113 | 113 | let sig = mallory.sign(&spectra_core::ledger::checkpoint_message(cp.seq - 1, &cp.prev)); |
|
| 114 | 114 | let proof = hex::encode(oxy_core::cbor::encode(&sig.to_value())); |
|
| 115 | 115 | raw(dir.path(), &format!("UPDATE events SET auth_proof = X'{proof}' WHERE seq = {}", cp.seq)).await; |
|
| 116 | - | let v = open(dir.path()).await.verify(&NoKeys).await.unwrap(); |
|
| 116 | + | let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
|
| 117 | 117 | assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
|
| 118 | 118 | assert_eq!(v.findings[0].problem, Problem::BadCheckpoint); |
|
| 119 | 119 | } |
|
| … | 20 unchanged lines |
||
| 140 | 140 | assert!(ledger.check().await.unwrap().is_empty(), "projections drifted from the ledger"); |
|
| 141 | 141 | ledger.rebuild().await.unwrap(); |
|
| 142 | 142 | assert!(ledger.check().await.unwrap().is_empty()); |
|
| 143 | - | let v = ledger.verify(&NoKeys).await.unwrap(); |
|
| 143 | + | let v = ledger.verify(&mut NoKeys).await.unwrap(); |
|
| 144 | 144 | assert!(v.clean(), "{:?}", &v.findings[..v.findings.len().min(3)]); |
|
| 145 | 145 | assert!(v.checkpoints >= 10_000 / CHECKPOINT_EVERY); |
|
| 146 | 146 | // Generous in a debug build; the release number is in docs/plan.md. |
|
| … | 29 unchanged lines |
||
| 176 | 176 | let ledger = open(dir.path()).await; |
|
| 177 | 177 | let e = ledger.append_one(msg(2)).await.unwrap(); |
|
| 178 | 178 | assert_eq!((e.seq, e.prev), (seq + 1, hash)); |
|
| 179 | - | assert!(ledger.verify(&NoKeys).await.unwrap().clean()); |
|
| 179 | + | assert!(ledger.verify(&mut NoKeys).await.unwrap().clean()); |
|
| 180 | 180 | } |
|
| 181 | 181 | ||
| 182 | 182 | #[tokio::test] |
|
| … | 12 unchanged lines |
modified docs/plan.md +2 −1
| … | 16 unchanged lines |
||
| 17 | 17 | - [x] #1 Project skeleton in Oxygen's shape — done 2026-11-02: workspace, deps/oxygen pinned, make check, deps-check (212 crates, all vendored). |
|
| 18 | 18 | - [x] #2 CI on OxygenFlow — done 2026-11-02: run #1 passed fmt, clippy, test, build with no image rebuild. |
|
| 19 | 19 | - [x] #3 The ledger — done 2026-11-02: chain, frozen envelope, HLC, signed checkpoints every 256 events, verify/check/rebuild; append 0.15 ms (release), 10,000 random events with rebuild and verify 1.3 s. |
|
| 20 | - | - [ ] #4 Identities instead of tokens |
|
| 20 | + | - [x] #4 Identities instead of tokens — done 2026-11-02: init, agent add (waits for the delegation), list, disable, identity show/refresh; Principals replay in verify; Authenticator (window, nonce, uid); write lock. Checked against the real `oxy identity delegate add`. The socket and its SO_PEERCRED arrive with #7, which uses the Authenticator. |
|
| 21 | 21 | - [ ] #5 Projects, presence and sessions |
|
| 22 | 22 | - [ ] #6 Channels, threads, messages, inbox and search |
|
| 23 | 23 | - [ ] #7 Tuner: MCP over Streamable HTTP |
|
| … | 8 unchanged lines |
||
| 32 | 32 | ||
| 33 | 33 | - 2026-11-02 plan created; sprint adjusted to spec Draft 0.3 (identities and the ledger). |
|
| 34 | 34 | - 2026-11-02 #1 and #2 done (CI green on run #1); #3 done. Root disk was full: build trees go to /mnt/development/spectra-target (CARGO_TARGET_DIR). |
|
| 35 | + | - 2026-11-02 #4 done. Found and fixed while testing: a signed event's actor was not checked against its signer. |
modified docs/spec.md +10 −7
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.3 | |
|
| 5 | + | | **Status** | Draft 0.3a | |
|
| 6 | 6 | | **Date** | 2026-11-02 | |
|
| 7 | 7 | | **Scope** | One Rust binary, `spectra`, that AI coding agents on one machine connect to over the Model Context Protocol (MCP) to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). | |
|
| 8 | 8 | | **Companion documents** | `../spectrahub/docs/spec.md`, `../spectra-vscode/docs/spec.md`, `../spectrahub-vscode/docs/spec.md` | |
|
| … | 246 unchanged lines |
||
| 255 | 255 | spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen |
|
| 256 | 256 | ``` |
|
| 257 | 257 | ||
| 258 | - | 1. Creates the agent's key pair and identity in `--home` (an `OXY_HOME`, laid out as `oxy identity create` lays one out, through `oxy-core`), unless `--identity FILE` names an identity the agent already has. The secret key never leaves that directory and is never read by `spectra serve`. |
|
| 259 | - | 2. Prints the one command the owner runs to make it theirs, and waits for it: `oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h`. The owner's current identity (§8.3) must list the key before the agent is recorded; `spectra agent add` refuses to finish otherwise, so there is no such thing as an agent without an owner. |
|
| 258 | + | 1. Refuses a name already taken. Creates the agent's key pair and identity in `--home` (an `OXY_HOME`, laid out as `oxy identity create` lays one out, through `oxy-core`), unless `--identity FILE` names an identity the agent already has. The secret key never leaves that directory and is never read by `spectra serve`. |
|
| 259 | + | 2. Prints the one command the owner runs to make it theirs, and waits for it (`--wait`, ten minutes by default; running `add` again resumes with the same key): `oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h`. The owner's current identity (§8.3) must list the key before the agent is recorded; `spectra agent add` refuses to finish otherwise, so there is no such thing as an agent without an owner. |
|
| 260 | 260 | 3. Records `agent.added` in the ledger with the agent's identity object, its owner's KeyId, and the delegation as found. |
|
| 261 | 261 | ||
| 262 | 262 | ``` |
|
| … | 14 unchanged lines |
||
| 277 | 277 | ||
| 278 | 278 | **Ownership is shown everywhere.** Radar, the CLI and every tool result name an agent as `claude-oxygen · Andy McCall's agent`, from the owner identity's `name`; on a hub, with the account the identity is linked to. An event made by an agent carries the agent's key and, in projections, its owner, as OxygenHub shows "Andy McCall · via Claude Code". |
|
| 279 | 279 | ||
| 280 | - | **Expiry and revocation are Oxygen's.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time (`delegates_to`, with `expires`). An expired delegation is refused with a message naming the `oxy identity delegate add` to run; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. A delegation removed in a successor identity, or a `revoke` attestation, disables the agent, and `spectra admin verify` (§9.5) marks what it signed as *valid but revoked*, as `oxy verify` does. The owner's successor identities reach Spectra through `spectra identity refresh` (reads `oxy identity export` from the owner's home) or through a hub that already links them (§23.3). |
|
| 280 | + | **Expiry and revocation are Oxygen's.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time (`delegates_to`, with `expires`). An expired delegation is refused with a message naming the `oxy identity delegate add` to run; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. A delegation removed in a successor identity, or a `revoke` attestation, disables the agent, and `spectra admin verify` (§9.5) marks what it signed as *valid but revoked*, as `oxy verify` does. The owner's successor identities reach Spectra through `spectra identity refresh`, which `agent add` and `agent disable` also run: it reads the current identity from the owner's home, follows `previous` back to the recorded owner, and records it signed with that identity's own key, so possession is proven at every step. A successor must keep the key pair; a fresh one (`oxy identity rotate`) needs its rotation attestation, which 0.1 does not accept yet. |
|
| 281 | 281 | ||
| 282 | 282 | An agent MAY belong to one project. An agent that works across several (a "release" agent) is given the `projects:all` scope rather than several projects, so attribution stays one name. |
|
| 283 | 283 | ||
| … | 34 unchanged lines |
||
| 318 | 318 | ||
| 319 | 319 | **Transports.** Local principals reach Spectra over the Unix socket `<data>/spectra.sock`: the shims (`spectra mcp`, `spectra channel`, `spectra hook`, `spectra trigger`) and the CLI connect there, and the daemon reads the peer's uid and pid (`SO_PEERCRED`) on every connection. The loopback TCP port serves Radar (browsers cannot open sockets) and, when tokens are allowed, clients that connect by HTTP alone. Both carry the same HTTP and the same MCP endpoint. |
|
| 320 | 320 | ||
| 321 | - | **Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, with the domain string `spectra1 request`, over `method || BLAKE3(body) || time || nonce`). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it. |
|
| 321 | + | **Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`, made through `oxy-core`'s challenge signing so the signed target is `BLAKE3("oxy1 challenge " || message)` and can never be taken for a change, an attestation or a policy). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it. |
|
| 322 | 322 | ||
| 323 | 323 | **Passkey assertions** are the proof of the human's Radar actions (§8.3). **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such. |
|
| 324 | 324 | ||
| … | 7 unchanged lines |
||
| 332 | 332 | ||
| 333 | 333 | **Policy** (`[policy]` in `spectra.toml`) names the level each class of the owner's actions needs. Defaults: `chat = 2`, `tasks = 2`, `human_answers = 3`, `permission_verdicts = 3`, `rules = 3`, `identities = 3`, `admin = 3` (pruning, retention, exports that leave the machine). An agent's actions are limited by its scopes (§8.4) and are level 2 at most; the classes above that are the owner's cannot be reached by an agent at any level. Lowering a class to 2 is allowed, for a single-user machine the owner trusts, and is itself a level-3 action that leaves a `policy.weakened` event and a standing banner in Radar. |
|
| 334 | 334 | ||
| 335 | - | **Key to uid.** An agent's record MAY carry the Unix uid its key is used from (`spectra agent add --uid`, or learnt from the first signed connection with `--learn-uid`). A request signed with that key from another uid is refused, and `guard.impersonation` is logged with both uids and pids and shown to the owner. The same key seen on two live connections from different pids under one uid is logged as `guard.key_shared`. These are detection, not prevention, under one uid (§5.3); with one uid per agent (§21.6) they are prevention. |
|
| 335 | + | **One writer.** Only one process appends to a data directory's ledger at a time: it holds an exclusive lock on `spectra.lock`, so the chain can never fork. While `spectra serve` runs, the CLI's writes go through it; reading and `spectra admin verify` need no lock. |
|
| 336 | 336 | ||
| 337 | + | **Key to uid.** An agent's record MAY carry the Unix uid its key is used from (`spectra agent add --uid`, or learnt from the first signed connection with `--learn-uid`, recorded as `agent.uid_bound`). A request signed with that key from another uid is refused, and `guard.impersonation` is logged with both uids and pids and shown to the owner. The same key seen on two live connections from different pids under one uid is logged as `guard.key_shared`. These are detection, not prevention, under one uid (§5.3); with one uid per agent (§21.6) they are prevention. |
|
| 337 | 338 | ||
| 338 | 339 | ||
| 340 | + | ||
| 339 | 341 | ## 9. The event log and the data model |
|
| 340 | 342 | ||
| 341 | 343 | ### 9.1 Events |
|
| … | 63 unchanged lines |
||
| 405 | 407 | ||
| 406 | 408 | - **Canonical encoding.** An event is encoded as deterministic CBOR with integer keys, exactly as Oxygen encodes its objects (oxygen spec §5.1, through `oxy-core`), and hashed as `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`, the domain-separated form of oxygen spec §5.2, with `hash` left out of the body. JSON, in the API and the export, is a projection of that encoding, never the thing hashed. |
|
| 407 | 409 | - **The chain.** `prev` is the previous event's `hash`; the first event's is zero. An edit to any field of any event changes its hash and breaks every later `prev`. Removing or reordering events does the same. A pruned event's stub keeps `prev` and `hash`, so the chain verifies across it. |
|
| 408 | - | - **Proofs.** `auth.proof` is the request signature (§8.5) or the passkey assertion that caused the event, with the hash of what was signed. Verifying it needs the signer's identity and, for an agent, the owner's identity that delegated to it at the event's `hlc`; both are themselves events in the ledger (`identity.*`, `agent.added`), so verification is closed over the ledger and needs nothing else. |
|
| 410 | + | - **Proofs.** `auth.proof` is the request signature (§8.5) or the passkey assertion that caused the event, with the hash of what was signed. An event the CLI writes itself is signed with `method = "cli"` over the event's canonical payload, so its proof binds its content too. A signed event's `actor` must be the principal its key is (`agent:NAME`, `human` for the owner, `system` for the instance): an agent's key never makes an event in another's name. Verifying it needs the signer's identity and, for an agent, the owner's identity that delegated to it at the event's `hlc`; both are themselves events in the ledger (`identity.*`, `agent.added`), so verification is closed over the ledger and needs nothing else. |
|
| 409 | 411 | - **Checkpoints.** Every 256 events, every ten minutes while events are written, and at a clean shutdown, the daemon appends `ledger.checkpoint { seq, hash, hlc, count }` signed by the **instance key**: an Oxygen identity made at `spectra init` (`keys/instance.secret`, 0600; its identity object is the `identity.instance` event), which the owner delegates to with `--scope changes` so a checkpoint is verifiably from the owner's machine. Checkpoints go up the uplink (§23.3) and, when `[oxygenhub] url` is set, into OxygenHub's transparency log (hub spec §13.5.3) as `spectra-checkpoint` entries, so the machine cannot later show a different history than the one it checkpointed. |
|
| 410 | 412 | - **Verification.** `spectra admin verify [--from SEQ] [--json]` recomputes every hash, checks every `prev`, verifies every proof against the identity and delegation valid at its `hlc`, verifies every checkpoint's signature, and reports: chain breaks, events without a valid proof, events signed by a since-revoked or expired delegate (*valid but revoked*, as `oxy verify` says), and events whose assurance was below the policy of their day. Exit 0 when clean, 1 otherwise. It runs nightly, records `ledger.verified`, and Radar's Log page shows the last result and the head's hash. `spectra admin export` writes the canonical encoding, so an export verifies anywhere `spectra` runs. |
|
| 411 | 413 | - **Signatures are outside the hash** (oxygen spec §5.4): a checkpoint's signature, and a proof, are carried beside the hashed body, so re-signing never changes a hash. |
|
| … | 578 unchanged lines |
||
| 990 | 992 | ||
| 991 | 993 | ## 27. Changelog |
|
| 992 | 994 | ||
| 995 | + | - Draft 0.3a (2026-11-02), while building #3 and #4: request and checkpoint signatures go through `oxy-core`'s challenge signing with Spectra's prefix inside the message (§8.5, §9.5); CLI-written events are signed over their payload, and a signed event's actor must be its signer (§9.5); one writer per data directory under a lock (§8.5); `identity refresh` proves possession at every successor and refuses a fresh key pair in 0.1 (§8.1); `agent add` waits for the delegation and resumes (§8.1). |
|
| 993 | 996 | - Draft 0.3 (2026-11-02), security: agents are their owner's Oxygen delegates with ownership verified and shown (§8.1, G7); no owner token: the owner signs from the CLI and uses a passkey in Radar (§8.3); a Unix socket with peer credentials, signed requests, assurance levels and the policy that keeps the human's authority out of agents' reach (§8.5, G8); the ledger: hash chain, proofs on every event, checkpoints signed by an instance key and witnessed by OxygenHub's transparency log, `spectra admin verify`, pruning to stubs (§9.5, G6); full tool-call payloads content addressed (§9.2); committed rules with their `oxy` change (§12.4); claimed changes verified (§23.5); one uid per agent as the recommended deployment and the limit stated (§5.3, §21.6). |
|
| 994 | 997 | - Draft 0.2 (2026-11-02), after review: `wait` is for in-task waiting only and is refused when idle (§14.1); `spectra hook stop --wait` and `spectra trigger` wake agents at no model cost (§14.3, §14.4); an owner token on every request, loopback included (§8.3); the lifecycle transitions folded into `task_update`, claims and schedules into one tool each, twenty tools with a token budget on `tools/list` (§15.2); a hybrid logical clock in the event envelope, which is frozen at 0.1 (§9.1); SpectraHub deferred until Spectra 0.3 is in daily use (hub spec §17). |
|
| 995 | 998 | - Draft 0.1 (2026-11-02): initial specification: event-sourced core, three delivery tiers against MCP 2026-07-28, tasks with contracts and lifecycle, claims, layered rules with versions, Echo search, Radar, guard rails, the Hub split and the Oxygen integration. |
modified src/cli/admin.rs +4 −3
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | use super::{ui, Ctx}; |
|
| 4 | 4 | use anyhow::Result; |
|
| 5 | - | use spectra_core::ledger::{Ledger, NoKeys}; |
|
| 5 | + | use spectra_core::ledger::Ledger; |
|
| 6 | + | use spectra_core::principals::Principals; |
|
| 6 | 7 | ||
| 7 | 8 | /// `spectra admin verify`: recompute the chain, check every checkpoint and |
|
| 8 | 9 | /// proof; exit 1 when anything is wrong. |
|
| 9 | 10 | pub async fn verify(ctx: &Ctx, json: bool) -> Result<i32> { |
|
| 10 | - | let ledger = Ledger::open(&ctx.db()?, None).await?; |
|
| 11 | - | let v = ledger.verify(&NoKeys).await?; |
|
| 11 | + | let ledger = Ledger::open_read_only(&ctx.db()?).await?; |
|
| 12 | + | let v = ledger.verify(&mut Principals::default()).await?; |
|
| 12 | 13 | if json { |
|
| 13 | 14 | println!("{}", serde_json::to_string(&v)?); |
|
| 14 | 15 | } else if v.clean() { |
|
| … | 41 unchanged lines |
modified src/cli/mod.rs +108 −0
| … | 2 unchanged lines |
||
| 3 | 3 | //! `NO_COLOR`; `--json` prints one document on stdout and nothing else. |
|
| 4 | 4 | ||
| 5 | 5 | pub mod admin; |
|
| 6 | + | pub mod agent; |
|
| 6 | 7 | pub mod format; |
|
| 8 | + | pub mod identity; |
|
| 7 | 9 | pub mod json; |
|
| 8 | 10 | pub mod ui; |
|
| 9 | 11 | ||
| … | 22 unchanged lines |
||
| 32 | 34 | #[arg(long)] |
|
| 33 | 35 | json: bool, |
|
| 34 | 36 | }, |
|
| 37 | + | /// Make this machine's Spectra: record the owner's identity (read from |
|
| 38 | + | /// their OXY_HOME and proven by signing) and make the instance key |
|
| 39 | + | Init { |
|
| 40 | + | /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy) |
|
| 41 | + | #[arg(long)] |
|
| 42 | + | owner_home: Option<PathBuf>, |
|
| 43 | + | /// This instance's name (default: the host name) |
|
| 44 | + | #[arg(long)] |
|
| 45 | + | name: Option<String>, |
|
| 46 | + | }, |
|
| 47 | + | /// The owner's and the instance's identities: show, refresh |
|
| 48 | + | Identity { |
|
| 49 | + | #[command(subcommand)] |
|
| 50 | + | command: IdentityCmd, |
|
| 51 | + | }, |
|
| 52 | + | /// Agents, each its owner's delegate: add, list, disable |
|
| 53 | + | Agent { |
|
| 54 | + | #[command(subcommand)] |
|
| 55 | + | command: AgentCmd, |
|
| 56 | + | }, |
|
| 35 | 57 | /// The ledger's own checks: verify, check, rebuild |
|
| 36 | 58 | Admin { |
|
| 37 | 59 | #[command(subcommand)] |
|
| 38 | 60 | command: Admin, |
|
| 61 | + | }, |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | #[derive(Subcommand)] |
|
| 65 | + | pub enum IdentityCmd { |
|
| 66 | + | /// The owner and the instance as the ledger knows them |
|
| 67 | + | Show { |
|
| 68 | + | #[arg(long)] |
|
| 69 | + | json: bool, |
|
| 70 | + | }, |
|
| 71 | + | /// Record the owner's current identity after `oxy identity delegate add|remove` |
|
| 72 | + | Refresh { |
|
| 73 | + | #[arg(long)] |
|
| 74 | + | owner_home: Option<PathBuf>, |
|
| 39 | 75 | }, |
|
| 40 | 76 | } |
|
| 41 | 77 | ||
| 42 | 78 | #[derive(Subcommand)] |
|
| 79 | + | pub enum AgentCmd { |
|
| 80 | + | /// Make an agent's key in its own home and record it once you delegate to it |
|
| 81 | + | Add { |
|
| 82 | + | /// Lower-case letters, digits and dashes |
|
| 83 | + | name: String, |
|
| 84 | + | /// claude-code, antigravity, kimi-code, codex or other |
|
| 85 | + | #[arg(long)] |
|
| 86 | + | kind: String, |
|
| 87 | + | /// The project it works in |
|
| 88 | + | #[arg(long)] |
|
| 89 | + | project: String, |
|
| 90 | + | /// The agent's own OXY_HOME, where its key is made and kept |
|
| 91 | + | #[arg(long)] |
|
| 92 | + | home: PathBuf, |
|
| 93 | + | /// Bind the key to this Unix user: a request from another is refused |
|
| 94 | + | #[arg(long)] |
|
| 95 | + | uid: Option<u32>, |
|
| 96 | + | /// Bind the key to the Unix user of its first signed connection |
|
| 97 | + | #[arg(long, conflicts_with = "uid")] |
|
| 98 | + | learn_uid: bool, |
|
| 99 | + | /// How long to wait for your delegation, in seconds |
|
| 100 | + | #[arg(long, default_value_t = 600)] |
|
| 101 | + | wait: u64, |
|
| 102 | + | /// The owner's OXY_HOME |
|
| 103 | + | #[arg(long)] |
|
| 104 | + | owner_home: Option<PathBuf>, |
|
| 105 | + | }, |
|
| 106 | + | /// Every agent with its owner, key, delegation and state |
|
| 107 | + | List { |
|
| 108 | + | #[arg(long)] |
|
| 109 | + | json: bool, |
|
| 110 | + | }, |
|
| 111 | + | /// Refuse an agent's key from now on |
|
| 112 | + | Disable { |
|
| 113 | + | name: String, |
|
| 114 | + | #[arg(long)] |
|
| 115 | + | owner_home: Option<PathBuf>, |
|
| 116 | + | }, |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | #[derive(Subcommand)] |
|
| 43 | 120 | pub enum Admin { |
|
| 44 | 121 | /// Recompute the hash chain and check every checkpoint and proof (exit 1 on any finding) |
|
| 45 | 122 | Verify { |
|
| … | 21 unchanged lines |
||
| 67 | 144 | } |
|
| 68 | 145 | Ok(db) |
|
| 69 | 146 | } |
|
| 147 | + | ||
| 148 | + | /// The ledger, for writing: the instance key signs its checkpoints, and |
|
| 149 | + | /// the lock keeps any other writer out. |
|
| 150 | + | pub async fn writer(&self) -> Result<spectra_core::ledger::Ledger> { |
|
| 151 | + | let db = self.db()?; |
|
| 152 | + | let instance = spectra_core::keys::KeyPair::instance(&self.data, "spectra")?; |
|
| 153 | + | spectra_core::ledger::Ledger::open(&db, Some(instance)).await |
|
| 154 | + | } |
|
| 155 | + | ||
| 156 | + | /// `[owner] home` from spectra.toml. |
|
| 157 | + | pub fn config_owner_home(&self) -> Option<PathBuf> { |
|
| 158 | + | let text = std::fs::read_to_string(self.data.join("spectra.toml")).ok()?; |
|
| 159 | + | let t: toml::Table = text.parse().ok()?; |
|
| 160 | + | t.get("owner")?.get("home")?.as_str().map(PathBuf::from) |
|
| 161 | + | } |
|
| 70 | 162 | } |
|
| 71 | 163 | ||
| 72 | 164 | fn default_data() -> PathBuf { |
|
| … | 14 unchanged lines |
||
| 87 | 179 | let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) }; |
|
| 88 | 180 | match cli.command { |
|
| 89 | 181 | Command::Version { json } => version(json), |
|
| 182 | + | Command::Init { owner_home, name } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), |
|
| 183 | + | Command::Identity { command } => runtime()?.block_on(async { |
|
| 184 | + | match command { |
|
| 185 | + | IdentityCmd::Show { json } => identity::show(&ctx, json).await, |
|
| 186 | + | IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, |
|
| 187 | + | } |
|
| 188 | + | }), |
|
| 189 | + | Command::Agent { command } => runtime()?.block_on(async { |
|
| 190 | + | match command { |
|
| 191 | + | AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => { |
|
| 192 | + | agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await |
|
| 193 | + | } |
|
| 194 | + | AgentCmd::List { json } => agent::list(&ctx, json).await, |
|
| 195 | + | AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await, |
|
| 196 | + | } |
|
| 197 | + | }), |
|
| 90 | 198 | Command::Admin { command } => runtime()?.block_on(async { |
|
| 91 | 199 | match command { |
|
| 92 | 200 | Admin::Verify { json } => admin::verify(&ctx, json).await, |
|
| … | 23 unchanged lines |
modified tests/cli_admin.rs +7 −1
| … | 22 unchanged lines |
||
| 23 | 23 | std::fs::create_dir_all(data).unwrap(); |
|
| 24 | 24 | let instance = KeyPair::instance(data, "test").unwrap(); |
|
| 25 | 25 | let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap(); |
|
| 26 | + | // As `spectra init` records it: signed by the instance key. |
|
| 26 | 27 | ledger |
|
| 27 | - | .append_one(NewEvent::system("identity.instance", serde_json::json!({ "identity": hex::encode(instance.identity.encode()) }))) |
|
| 28 | + | .append_one(spectra_core::principals::signed_event( |
|
| 29 | + | &instance, |
|
| 30 | + | "system", |
|
| 31 | + | "identity.instance", |
|
| 32 | + | spectra_core::principals::identity_payload(&instance.identity), |
|
| 33 | + | )) |
|
| 28 | 34 | .await |
|
| 29 | 35 | .unwrap(); |
|
| 30 | 36 | for i in 0..n { |
|
| … | 37 unchanged lines |