OXYGENHUB
◆ main spectra / crates / spectra-core / src / principals.rs
20.6 KiB · Rust · last changed spectra agent allow: let one agent post in another project's channel 1 day ago source blame history raw
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::identity::Identity;
20
use crate::identity::KeyId;
21
use crate::ledger::KeyResolver;
22
use crate::proof::Proof;
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
/// Who caused something.
31
#[derive(Clone, Debug, PartialEq, Eq)]
32
pub enum Principal {
33
    Owner { key: KeyId, name: String },
34
    Agent { name: String, key: KeyId, owner_name: String, uid: Option<u32>, learn_uid: bool, project: String, scopes: Vec<String> },
35
    Instance { key: KeyId },
36
}
37
38
impl Principal {
39
    /// The `actor` events carry: `human`, `agent:NAME` or `system`.
40
    pub fn actor(&self) -> String {
41
        match self {
42
            Principal::Owner { .. } => "human".into(),
43
            Principal::Agent { name, .. } => format!("agent:{name}"),
44
            Principal::Instance { .. } => "system".into(),
45
        }
46
    }
47
    /// How it is shown to people: `claude-oxygen · Andy McCall's agent`.
48
    pub fn display(&self) -> String {
49
        match self {
50
            Principal::Owner { name, .. } => name.clone(),
51
            Principal::Agent { name, owner_name, .. } => format!("{name} · {owner_name}'s agent"),
52
            Principal::Instance { .. } => "Spectra".into(),
53
        }
54
    }
55
}
56
57
#[derive(Clone, Debug)]
58
struct AgentRec {
59
    name: String,
60
    identity: Identity,
61
    owner_name: String,
62
    uid: Option<u32>,
63
    learn_uid: bool,
64
    project: String,
65
    scopes: Vec<String>,
66
    disabled: bool,
67
}
68
69
/// The principals as of some point in the ledger.
70
#[derive(Clone, Debug, Default)]
71
pub struct Principals {
72
    /// The owner's identities, oldest first; the last is current.
73
    owner: Vec<Identity>,
74
    instance: Option<Identity>,
75
    agents: HashMap<KeyId, AgentRec>,
76
    by_name: HashMap<String, KeyId>,
77
}
78
79
impl Principals {
80
    /// Replay every identity and agent event of the ledger.
81
    pub async fn load(ledger: &crate::ledger::Ledger) -> anyhow::Result<Principals> {
82
        let mut p = Principals::default();
83
        let mut from = 1;
84
        loop {
85
            let rows = ledger
86
                .events_of_kinds(
87
                    from,
88
                    &["identity.owner", "identity.rotated", "identity.instance", "agent.added", "agent.disabled", "agent.scoped"],
89
                    1000,
90
                )
91
                .await?;
92
            if rows.is_empty() {
93
                break;
94
            }
95
            for e in &rows {
96
                // Recorded facts were checked when written; `verify` checks them again.
97
                let _ = p.observe(e);
98
            }
99
            from = rows.last().map(|e| e.seq + 1).unwrap_or(from);
100
        }
101
        Ok(p)
102
    }
103
104
    /// The owner's current identity.
105
    pub fn owner(&self) -> Option<&Identity> {
106
        self.owner.last()
107
    }
108
109
    pub fn instance(&self) -> Option<&Identity> {
110
        self.instance.as_ref()
111
    }
112
113
    /// The agent named `name`, whatever its state.
114
    pub fn agent_key(&self, name: &str) -> Option<KeyId> {
115
        self.by_name.get(name).copied()
116
    }
117
118
    /// Who `key` is at `at_ms`, if anyone may act with it then: the owner
119
    /// (any of its identities' KeyIds), the instance, or an agent that is
120
    /// not disabled and that the owner's current identity delegates to.
121
    pub fn principal(&self, key: &KeyId, at_ms: u64) -> Result<Principal, String> {
122
        if let Some(owner) = self.owner.iter().rev().find(|i| &i.hash() == key) {
123
            return Ok(Principal::Owner { key: *key, name: owner.name.clone() });
124
        }
125
        if self.instance.as_ref().is_some_and(|i| &i.hash() == key) {
126
            return Ok(Principal::Instance { key: *key });
127
        }
128
        let a = self.agents.get(key).ok_or("an unknown key")?;
129
        if a.disabled {
130
            return Err(format!("agent {} is disabled", a.name));
131
        }
132
        let owner = self.owner().ok_or("no owner is recorded")?;
133
        if !owner.delegates_to(key, at_ms) {
134
            return Err(match owner.delegate(key) {
135
                Some(d) if d.expires.is_some_and(|x| at_ms > x) => format!("agent {}'s delegation from {} has expired", a.name, owner.name),
136
                Some(_) => format!("{}'s delegation to agent {} does not include `changes`", owner.name, a.name),
137
                None => format!("{}'s current identity does not delegate to agent {}", owner.name, a.name),
138
            });
139
        }
140
        Ok(Principal::Agent {
141
            name: a.name.clone(),
142
            key: *key,
143
            owner_name: a.owner_name.clone(),
144
            uid: a.uid,
145
            learn_uid: a.learn_uid,
146
            project: a.project.clone(),
147
            scopes: a.scopes.clone(),
148
        })
149
    }
150
151
    /// The identity behind `key`, for checking its signatures.
152
    pub fn identity(&self, key: &KeyId) -> Option<&Identity> {
153
        self.owner
154
            .iter()
155
            .find(|i| &i.hash() == key)
156
            .or_else(|| self.instance.as_ref().filter(|i| &i.hash() == key))
157
            .or_else(|| self.agents.get(key).map(|a| &a.identity))
158
    }
159
160
    /// Take one event into account, checking what it claims first.
161
    pub fn observe(&mut self, e: &Event) -> Result<(), String> {
162
        let p = e.json();
163
        match e.kind.as_str() {
164
            "identity.owner" => {
165
                if !self.owner.is_empty() {
166
                    return Err("an owner is already recorded".into());
167
                }
168
                let id = identity_of(&p)?;
169
                self_signed(e, &id)?;
170
                self.owner.push(id);
171
            }
172
            "identity.rotated" => {
173
                let new = identity_of(&p)?;
174
                let cur = self.owner().ok_or("no owner to succeed")?.clone();
175
                accept_successor(&cur, &new, &p)?;
176
                self_signed(e, &new)?;
177
                self.owner.push(new);
178
            }
179
            "identity.instance" => {
180
                let id = identity_of(&p)?;
181
                self_signed(e, &id)?;
182
                self.instance = Some(id);
183
            }
184
            "agent.added" => {
185
                let id = identity_of(&p)?;
186
                let key = id.hash();
187
                let owner = self.owner_signed(e)?;
188
                if !owner.delegates_to(&key, e.hlc.wall_ms) {
189
                    return Err("added without the owner's delegation to its key".into());
190
                }
191
                let name = text(&p, "name")?;
192
                if self.by_name.get(&name).is_some_and(|k| *k != key) {
193
                    return Err(format!("agent {name} already exists with another key"));
194
                }
195
                self.by_name.insert(name.clone(), key);
196
                self.agents.insert(
197
                    key,
198
                    AgentRec {
199
                        name,
200
                        identity: id,
201
                        owner_name: owner.name.clone(),
202
                        uid: p.get("uid").and_then(Json::as_u64).map(|u| u as u32),
203
                        learn_uid: p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false),
204
                        project: text(&p, "project")?,
205
                        scopes: p
206
                            .get("scopes")
207
                            .and_then(Json::as_array)
208
                            .map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
209
                            .unwrap_or_default(),
210
                        disabled: false,
211
                    },
212
                );
213
            }
214
            "agent.uid_bound" => {
215
                // The daemon binds a learning agent to the uid of its first
216
                // signed connection; covered by the next checkpoint.
217
                let name = text(&p, "name")?;
218
                let uid = p.get("uid").and_then(Json::as_u64).ok_or("no uid")? as u32;
219
                let key = *self.by_name.get(&name).ok_or("an unknown agent")?;
220
                if let Some(a) = self.agents.get_mut(&key) {
221
                    if a.uid.is_none() {
222
                        a.uid = Some(uid);
223
                    }
224
                }
225
            }
226
            "agent.disabled" => {
227
                self.owner_signed(e)?;
228
                let name = text(&p, "name")?;
229
                let key = *self.by_name.get(&name).ok_or("an unknown agent")?;
230
                if let Some(a) = self.agents.get_mut(&key) {
231
                    a.disabled = true;
232
                }
233
            }
234
            // The owner grants or takes back one scope (spec §8.4), such
235
            // as `channel:NAME` to post in another project's channel.
236
            "agent.scoped" => {
237
                self.owner_signed(e)?;
238
                let name = text(&p, "name")?;
239
                let scope = text(&p, "scope")?;
240
                if !grantable(&scope) {
241
                    return Err(format!("{scope} is not a scope an agent may be given"));
242
                }
243
                let grant = p.get("grant").and_then(Json::as_bool).unwrap_or(true);
244
                let key = *self.by_name.get(&name).ok_or("an unknown agent")?;
245
                if let Some(a) = self.agents.get_mut(&key) {
246
                    a.scopes.retain(|s| s != &scope);
247
                    if grant {
248
                        a.scopes.push(scope);
249
                    }
250
                }
251
            }
252
            _ => {}
253
        }
254
        Ok(())
255
    }
256
257
    /// Check that `e` is signed by the owner's current identity; returns it.
258
    fn owner_signed(&self, e: &Event) -> Result<Identity, String> {
259
        let owner = self.owner().ok_or("no owner is recorded")?.clone();
260
        if e.auth.key != Some(owner.hash().0) {
261
            return Err("not signed by the owner's current identity".into());
262
        }
263
        check_proof(e, &owner)?;
264
        Ok(owner)
265
    }
266
267
    /// An event from a Radar session: its proof is the owner's signed
268
    /// request for the sign-in link, so it must verify against the owner,
269
    /// and only the owner acts this way.
270
    pub fn check_session(&self, e: &Event) -> Result<(), String> {
271
        let key = crate::identity::KeyId(e.auth.key.ok_or("a session event names no key")?);
272
        if !matches!(self.principal(&key, e.hlc.wall_ms)?, Principal::Owner { .. }) || e.actor != "human" {
273
            return Err("only the owner acts through a Radar session".into());
274
        }
275
        let identity = self.identity(&key).ok_or("an unknown key")?;
276
        let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?;
277
        if proof.key() != key.0 || proof.method != "POST /radar/link" {
278
            return Err("the session's proof is not the owner's sign-in request".into());
279
        }
280
        proof.verify(identity, None)
281
    }
282
283
    /// Check a signed event's proof against the principal its key was at
284
    /// the event's time.
285
    pub fn check_signed(&self, e: &Event) -> Result<(), String> {
286
        let key = crate::identity::KeyId(e.auth.key.ok_or("signed, but names no key")?);
287
        let who = self.principal(&key, e.hlc.wall_ms)?;
288
        // The actor an event names must be the principal that signed it:
289
        // an agent's key never makes an event in another's name.
290
        if who.actor() != e.actor {
291
            return Err(format!("names {} as its actor but is signed by {}", e.actor, who.display()));
292
        }
293
        let identity = self.identity(&key).ok_or("an unknown key")?;
294
        check_proof(e, identity)
295
    }
296
}
297
298
impl KeyResolver for Principals {
299
    fn check(&mut self, e: &Event) -> Result<(), String> {
300
        match e.kind.as_str() {
301
            "identity.owner" | "identity.rotated" | "identity.instance" | "agent.added" | "agent.disabled" | "agent.scoped" => {
302
                if e.auth.method != Method::Signature {
303
                    return Err(format!("{} must be signed", e.kind));
304
                }
305
                self.observe(e)
306
            }
307
            "agent.uid_bound" => self.observe(e),
308
            _ if matches!(e.auth.method, Method::Signature | Method::Passkey) => self.check_signed(e),
309
            _ if e.auth.method == Method::Session => self.check_session(e),
310
            _ => Ok(()),
311
        }
312
    }
313
}
314
315
/// Apply identity and agent events to the projection tables.
316
pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> {
317
    if e.kind == "agent.uid_bound" {
318
        let p = e.json();
319
        sqlx::query("UPDATE agents SET uid = ? WHERE name = ? AND uid IS NULL")
320
            .bind(p.get("uid").and_then(Json::as_i64))
321
            .bind(p.get("name").and_then(Json::as_str))
322
            .execute(&mut *conn)
323
            .await?;
324
        return Ok(());
325
    }
326
    // Who is who is only ever said by a signature; `verify` checks which.
327
    if e.auth.method != Method::Signature {
328
        return Ok(());
329
    }
330
    let p = e.json();
331
    let id = |p: &Json| identity_of(p).ok();
332
    match e.kind.as_str() {
333
        "identity.owner" | "identity.rotated" | "identity.instance" => {
334
            let Some(identity) = id(&p) else { return Ok(()) };
335
            let role = if e.kind == "identity.instance" { "instance" } else { "owner" };
336
            sqlx::query("UPDATE identities SET current = 0 WHERE role = ?").bind(role).execute(&mut *conn).await?;
337
            sqlx::query(
338
                "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, ?, ?, ?, ?, 1, ?)",
339
            )
340
            .bind(identity.hash().to_hex())
341
            .bind(role)
342
            .bind(&identity.name)
343
            .bind(identity.encode())
344
            .bind(identity.previous.map(|k| k.to_hex()))
345
            .bind(e.seq as i64)
346
            .execute(&mut *conn)
347
            .await?;
348
        }
349
        "agent.added" => {
350
            let Some(identity) = id(&p) else { return Ok(()) };
351
            if p.get("name").and_then(Json::as_str).is_none_or(str::is_empty) {
352
                return Ok(());
353
            }
354
            let s = |k: &str| p.get(k).and_then(Json::as_str).unwrap_or("").to_string();
355
            sqlx::query(
356
                "INSERT OR REPLACE INTO agents (name, key, owner, owner_name, kind, project, home, uid, learn_uid, scopes, created_ms, disabled_ms, seq) \
357
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?)",
358
            )
359
            .bind(s("name"))
360
            .bind(identity.hash().to_hex())
361
            .bind(s("owner"))
362
            .bind(s("owner_name"))
363
            .bind(s("kind"))
364
            .bind(s("project"))
365
            .bind(s("home"))
366
            .bind(p.get("uid").and_then(Json::as_i64))
367
            .bind(p.get("learn_uid").and_then(Json::as_bool).unwrap_or(false))
368
            .bind(p.get("scopes").map(|v| v.to_string()).unwrap_or_else(|| "[]".into()))
369
            .bind(e.at_ms as i64)
370
            .bind(e.seq as i64)
371
            .execute(&mut *conn)
372
            .await?;
373
            sqlx::query(
374
                "INSERT OR REPLACE INTO identities (key, role, name, identity, previous, current, seq) VALUES (?, 'agent', ?, ?, ?, 1, ?)",
375
            )
376
            .bind(identity.hash().to_hex())
377
            .bind(&identity.name)
378
            .bind(identity.encode())
379
            .bind(identity.previous.map(|k| k.to_hex()))
380
            .bind(e.seq as i64)
381
            .execute(&mut *conn)
382
            .await?;
383
        }
384
        "agent.disabled" => {
385
            sqlx::query("UPDATE agents SET disabled_ms = ? WHERE name = ?")
386
                .bind(e.at_ms as i64)
387
                .bind(p.get("name").and_then(Json::as_str))
388
                .execute(&mut *conn)
389
                .await?;
390
        }
391
        "agent.scoped" if e.actor == "human" => {
392
            let (Some(name), Some(scope)) = (p.get("name").and_then(Json::as_str), p.get("scope").and_then(Json::as_str)) else {
393
                return Ok(());
394
            };
395
            if !grantable(scope) {
396
                return Ok(());
397
            }
398
            let grant = p.get("grant").and_then(Json::as_bool).unwrap_or(true);
399
            let current: Option<String> =
400
                sqlx::query_scalar("SELECT scopes FROM agents WHERE name = ?").bind(name).fetch_optional(&mut *conn).await?;
401
            let mut scopes: Vec<String> = current.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
402
            scopes.retain(|s| s != scope);
403
            if grant {
404
                scopes.push(scope.to_string());
405
            }
406
            sqlx::query("UPDATE agents SET scopes = ? WHERE name = ?")
407
                .bind(serde_json::to_string(&scopes).unwrap_or_default())
408
                .bind(name)
409
                .execute(&mut *conn)
410
                .await?;
411
        }
412
        _ => {}
413
    }
414
    Ok(())
415
}
416
417
/// A scope the owner may give an agent (spec §8.4): never the owner's own
418
/// authority, only reach.
419
pub fn grantable(scope: &str) -> bool {
420
    match scope.strip_prefix("channel:") {
421
        Some(ch) => !ch.is_empty() && ch.len() <= 64 && ch.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'),
422
        None => matches!(scope, "projects:all" | "schedule:any" | "webhooks:read"),
423
    }
424
}
425
426
/// The payload of an identity event for `identity`.
427
pub fn identity_payload(identity: &Identity) -> Json {
428
    serde_json::json!({ "key": identity.hash().to_hex(), "name": identity.name, "identity": hex::encode(identity.encode()) })
429
}
430
431
fn identity_of(p: &Json) -> Result<Identity, String> {
432
    let b = p.get("identity").and_then(Json::as_str).and_then(|h| hex::decode(h).ok()).ok_or("no identity in the event")?;
433
    Identity::decode(&b).map_err(|e| format!("malformed identity: {e}"))
434
}
435
436
fn text(p: &Json, k: &str) -> Result<String, String> {
437
    p.get(k).and_then(Json::as_str).map(String::from).ok_or_else(|| format!("no {k}"))
438
}
439
440
fn self_signed(e: &Event, id: &Identity) -> Result<(), String> {
441
    if e.auth.key != Some(id.hash().0) {
442
        return Err("not signed by the identity it records".into());
443
    }
444
    check_proof(e, id)
445
}
446
447
/// The event's proof verifies against `identity`, and a CLI proof covers
448
/// exactly the event's payload.
449
fn check_proof(e: &Event, identity: &Identity) -> Result<(), String> {
450
    let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?;
451
    if proof.key() != identity.hash().0 {
452
        return Err("the proof is by another key".into());
453
    }
454
    let body = (proof.method == "cli").then(|| e.payload.as_deref().unwrap_or(b""));
455
    proof.verify(identity, body)
456
}
457
458
/// A successor of the owner must keep the key pair and chain back to the
459
/// current identity through `previous` (the intermediate identities, if
460
/// any, are carried in the event as `chain`).
461
fn accept_successor(cur: &Identity, new: &Identity, p: &Json) -> Result<(), String> {
462
    if new.ed25519_pk != cur.ed25519_pk || new.mldsa65_pk != cur.mldsa65_pk {
463
        return Err("a successor with a fresh key pair needs `oxy identity rotate`'s attestation, not accepted in 0.1".into());
464
    }
465
    let chain: Vec<Identity> = p
466
        .get("chain")
467
        .and_then(Json::as_array)
468
        .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())
469
        .unwrap_or_default();
470
    let mut at = new.clone();
471
    for _ in 0..=chain.len() {
472
        match at.previous {
473
            Some(prev) if prev == cur.hash() => return Ok(()),
474
            Some(prev) => match chain.iter().find(|i| i.hash() == prev) {
475
                Some(i) if i.ed25519_pk == cur.ed25519_pk => at = i.clone(),
476
                _ => break,
477
            },
478
            None => break,
479
        }
480
    }
481
    Err("does not succeed the recorded owner".into())
482
}
483
484
/// An event signed by `pair` from the command line: its proof covers the
485
/// canonical payload, so the event's content is bound to the signature.
486
pub fn signed_event(pair: &crate::keys::KeyPair, actor: &str, kind: &str, payload: Json) -> crate::event::NewEvent {
487
    let body = crate::event::canonical_json(&payload);
488
    let proof = Proof::sign(pair, "cli", &body);
489
    crate::event::NewEvent::system(kind, payload)
490
        .by(actor, crate::event::Auth { key: Some(pair.key.0), method: Method::Signature, assurance: 2, proof: Some(proof.encode()) })
491
}