OXYGENHUB
◆ main spectra / crates / spectra-core / tests / principals.rs
11.6 KiB · Rust · last changed Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 1 day ago source blame history raw
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 spectra_core::authn::{Authenticator, Refusal};
7
use spectra_core::event::NewEvent;
8
use spectra_core::identity::{Delegate, Identity};
9
use spectra_core::ids::now_ms;
10
use spectra_core::keys::KeyPair;
11
use spectra_core::ledger::{Ledger, Problem};
12
use spectra_core::principals::{identity_payload, signed_event, Principal, Principals};
13
use spectra_core::proof::Proof;
14
use std::path::Path;
15
16
/// The owner delegates to `agent`: a successor with the same key pair,
17
/// made current in the owner's home. Only the owner's own tools do this.
18
fn delegate(owner_home: &Path, agent: &Identity, expires: Option<u64>) -> KeyPair {
19
    spectra_core::keys::delegate(owner_home, agent.hash(), expires).unwrap()
20
}
21
22
struct World {
23
    _dir: tempfile::TempDir,
24
    owner_home: std::path::PathBuf,
25
    ledger: Ledger,
26
    owner: KeyPair,
27
}
28
29
async fn world() -> World {
30
    let dir = tempfile::tempdir().unwrap();
31
    let owner_home = dir.path().join("owner");
32
    let owner = KeyPair::create_home(&owner_home, "Andy McCall", "[email protected]").unwrap();
33
    let data = dir.path().join("data");
34
    std::fs::create_dir_all(&data).unwrap();
35
    let instance = KeyPair::instance(&data, "laptop").unwrap();
36
    let ledger = Ledger::open(&data.join("spectra.db"), Some(instance.clone())).await.unwrap();
37
    ledger.append_one(signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity))).await.unwrap();
38
    ledger.append_one(signed_event(&instance, "system", "identity.instance", identity_payload(&instance.identity))).await.unwrap();
39
    World { _dir: dir, owner_home, ledger, owner }
40
}
41
42
/// Add `name` as the owner would: delegate, record the successor, add.
43
async fn add_agent(w: &mut World, name: &str, expires: Option<u64>) -> KeyPair {
44
    let agent = KeyPair::generate(name, "");
45
    let prev = w.owner.identity.clone();
46
    w.owner = delegate(&w.owner_home, &agent.identity, expires);
47
    let mut rotated = identity_payload(&w.owner.identity);
48
    rotated["chain"] = serde_json::json!([]);
49
    assert_eq!(w.owner.identity.previous, Some(prev.hash()));
50
    w.ledger.append_one(signed_event(&w.owner, "human", "identity.rotated", rotated)).await.unwrap();
51
    let mut p = identity_payload(&agent.identity);
52
    p["name"] = name.into();
53
    p["kind"] = "claude-code".into();
54
    p["project"] = "oxygen".into();
55
    p["owner"] = w.owner.key.to_hex().into();
56
    p["owner_name"] = "Andy McCall".into();
57
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap();
58
    agent
59
}
60
61
fn said(agent: &KeyPair, actor: &str, body: &str) -> NewEvent {
62
    signed_event(agent, actor, "message.posted", serde_json::json!({ "body": body }))
63
}
64
65
#[tokio::test]
66
async fn an_owned_agent_acts_and_the_ledger_verifies_every_signature() {
67
    let mut w = world().await;
68
    let a = add_agent(&mut w, "claude-oxygen", None).await;
69
    w.ledger.append_one(said(&a, "agent:claude-oxygen", "hello")).await.unwrap();
70
    w.ledger.checkpoint().await.unwrap();
71
    let mut p = Principals::default();
72
    let v = w.ledger.verify(&mut p).await.unwrap();
73
    assert!(v.clean(), "{:?}", v.findings);
74
    let who = p.principal(&a.key, now_ms()).unwrap();
75
    assert_eq!(who.display(), "claude-oxygen · Andy McCall's agent");
76
    // The projection agrees, and so does a fresh load.
77
    assert!(w.ledger.check().await.unwrap().is_empty());
78
    let loaded = Principals::load(&w.ledger).await.unwrap();
79
    assert!(matches!(loaded.principal(&a.key, now_ms()), Ok(Principal::Agent { .. })));
80
}
81
82
#[tokio::test]
83
async fn an_agent_without_the_owners_delegation_cannot_be_added() {
84
    let w = world().await;
85
    let stray = KeyPair::generate("stray", "");
86
    let mut p = identity_payload(&stray.identity);
87
    p["name"] = "stray".into();
88
    p["project"] = "oxygen".into();
89
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap();
90
    w.ledger.append_one(said(&stray, "agent:stray", "I am somebody's")).await.unwrap();
91
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
92
    let bad: Vec<_> = v.findings.iter().filter(|f| f.problem == Problem::BadProof).collect();
93
    assert_eq!(bad.len(), 2, "the addition and the message: {:?}", v.findings);
94
    assert!(bad[0].detail.contains("without the owner's delegation"), "{}", bad[0].detail);
95
}
96
97
#[tokio::test]
98
async fn an_agent_cannot_act_in_another_agents_name_or_the_owners() {
99
    let mut w = world().await;
100
    let a = add_agent(&mut w, "claude-oxygen", None).await;
101
    let _b = add_agent(&mut w, "antigravity-hub", None).await;
102
    w.ledger.append_one(said(&a, "agent:antigravity-hub", "I am antigravity")).await.unwrap();
103
    w.ledger.append_one(said(&a, "human", "I am Andy")).await.unwrap();
104
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
105
    assert_eq!(v.findings.len(), 2, "{:?}", v.findings);
106
    assert!(v.findings.iter().all(|f| f.detail.contains("is signed by claude-oxygen")));
107
}
108
109
#[tokio::test]
110
async fn an_agent_cannot_record_itself_as_the_owner_or_a_successor() {
111
    let mut w = world().await;
112
    let a = add_agent(&mut w, "claude-oxygen", None).await;
113
    // A successor of the owner that the agent made: it has no owner key to sign it with.
114
    let forged = w.owner.identity.successor(vec![Delegate { key: a.key, expires: None }], None, now_ms());
115
    let mut p = identity_payload(&forged);
116
    p["chain"] = serde_json::json!([]);
117
    w.ledger.append_one(signed_event(&a, "human", "identity.rotated", p)).await.unwrap();
118
    // And a second owner, signed by itself.
119
    w.ledger.append_one(signed_event(&a, "human", "identity.owner", identity_payload(&a.identity))).await.unwrap();
120
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
121
    assert_eq!(v.findings.len(), 2, "{:?}", v.findings);
122
}
123
124
#[tokio::test]
125
async fn an_expired_or_disabled_agent_is_refused_and_flagged() {
126
    let mut w = world().await;
127
    let a = add_agent(&mut w, "overnight", Some(now_ms() + 60_000)).await;
128
    let principals = Principals::load(&w.ledger).await.unwrap();
129
    assert!(principals.principal(&a.key, now_ms()).is_ok());
130
    let later = now_ms() + 120_000;
131
    let why = principals.principal(&a.key, later).unwrap_err();
132
    assert!(why.contains("expired"), "{why}");
133
134
    let b = add_agent(&mut w, "claude-oxygen", None).await;
135
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.disabled", serde_json::json!({ "name": "claude-oxygen" }))).await.unwrap();
136
    w.ledger.append_one(said(&b, "agent:claude-oxygen", "still here?")).await.unwrap();
137
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
138
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
139
    assert!(v.findings[0].detail.contains("disabled"));
140
}
141
142
#[tokio::test]
143
async fn a_proof_swapped_from_another_event_is_caught() {
144
    let mut w = world().await;
145
    let a = add_agent(&mut w, "claude-oxygen", None).await;
146
    let first = w.ledger.append_one(said(&a, "agent:claude-oxygen", "one")).await.unwrap();
147
    let second = w.ledger.append_one(said(&a, "agent:claude-oxygen", "two")).await.unwrap();
148
    let pool = w.ledger.pool().clone();
149
    sqlx::query("UPDATE events SET auth_proof = ? WHERE seq = ?")
150
        .bind(first.auth.proof.clone())
151
        .bind(second.seq as i64)
152
        .execute(&pool)
153
        .await
154
        .unwrap();
155
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
156
    assert_eq!(v.findings.len(), 1, "{:?}", v.findings);
157
    assert!(v.findings[0].detail.contains("different body"));
158
}
159
160
#[tokio::test]
161
async fn requests_are_refused_unless_signed_fresh_unreplayed_and_from_the_bound_uid() {
162
    let mut w = world().await;
163
    let a = add_agent(&mut w, "claude-oxygen", None).await;
164
    let principals = Principals::load(&w.ledger).await.unwrap();
165
    let auth = Authenticator::default();
166
    let now = now_ms();
167
    let body = br#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#;
168
169
    assert_eq!(auth.authenticate(&principals, None, "POST /mcp", body, now, Some(1000)).unwrap_err(), Refusal::Unsigned);
170
    let good = Proof::sign(&a, "POST /mcp", body);
171
    let caller = auth.authenticate(&principals, Some(&good), "POST /mcp", body, now, Some(1000)).unwrap();
172
    assert_eq!(caller.principal.actor(), "agent:claude-oxygen");
173
    assert_eq!(caller.auth.assurance, 2);
174
    assert_eq!(auth.authenticate(&principals, Some(&good), "POST /mcp", body, now, Some(1000)).unwrap_err(), Refusal::Replay);
175
    let other_body = Proof::sign(&a, "POST /mcp", body);
176
    assert!(matches!(auth.authenticate(&principals, Some(&other_body), "POST /mcp", b"{}", now, None), Err(Refusal::BadSignature(_))));
177
    let old = Proof::sign_at(&a, "POST /mcp", body, now - 10 * 60_000, [7; 16]);
178
    assert!(matches!(auth.authenticate(&principals, Some(&old), "POST /mcp", body, now, None), Err(Refusal::Stale(_))));
179
    let wrong = Proof::sign(&a, "POST /api/v1/channels", body);
180
    assert!(matches!(auth.authenticate(&principals, Some(&wrong), "POST /mcp", body, now, None), Err(Refusal::WrongMethod(_))));
181
    let stranger = KeyPair::generate("stranger", "");
182
    assert!(matches!(
183
        auth.authenticate(&principals, Some(&Proof::sign(&stranger, "POST /mcp", body)), "POST /mcp", body, now, None),
184
        Err(Refusal::NotAPrincipal(_))
185
    ));
186
    let owner = auth.authenticate(&principals, Some(&Proof::sign(&w.owner, "POST /mcp", body)), "POST /mcp", body, now, None).unwrap();
187
    assert_eq!(owner.principal.actor(), "human");
188
189
    // An agent bound to a Unix user, used from another.
190
    let b = add_agent_bound(&mut w, "bound", 1001).await;
191
    let principals = Principals::load(&w.ledger).await.unwrap();
192
    let r = auth.authenticate(&principals, Some(&Proof::sign(&b, "POST /mcp", body)), "POST /mcp", body, now, Some(1000));
193
    assert_eq!(r.unwrap_err(), Refusal::WrongUid { agent: "bound".into(), bound: 1001, from: 1000 });
194
    assert!(auth.authenticate(&principals, Some(&Proof::sign(&b, "POST /mcp", body)), "POST /mcp", body, now, Some(1001)).is_ok());
195
}
196
197
async fn add_agent_bound(w: &mut World, name: &str, uid: u32) -> KeyPair {
198
    let agent = KeyPair::generate(name, "");
199
    w.owner = delegate(&w.owner_home, &agent.identity, None);
200
    let mut rotated = identity_payload(&w.owner.identity);
201
    rotated["chain"] = serde_json::json!([]);
202
    w.ledger.append_one(signed_event(&w.owner, "human", "identity.rotated", rotated)).await.unwrap();
203
    let mut p = identity_payload(&agent.identity);
204
    p["name"] = name.into();
205
    p["project"] = "oxygen".into();
206
    p["uid"] = uid.into();
207
    w.ledger.append_one(signed_event(&w.owner, "human", "agent.added", p)).await.unwrap();
208
    agent
209
}
210
211
#[tokio::test]
212
async fn an_unsigned_claim_to_be_the_owner_or_an_agent_never_reaches_the_tables() {
213
    let w = world().await;
214
    let fake = KeyPair::generate("mallory", "");
215
    let mut p = identity_payload(&fake.identity);
216
    p["name"] = "mallory".into();
217
    p["project"] = "oxygen".into();
218
    w.ledger.append_one(NewEvent::system("agent.added", p)).await.unwrap();
219
    w.ledger.append_one(NewEvent::system("identity.rotated", identity_payload(&fake.identity))).await.unwrap();
220
    let agents: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM agents").fetch_one(w.ledger.pool()).await.unwrap();
221
    let owners: Vec<String> =
222
        sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1").fetch_all(w.ledger.pool()).await.unwrap();
223
    assert_eq!(agents, 0);
224
    assert_eq!(owners, ["Andy McCall"]);
225
    let v = w.ledger.verify(&mut Principals::default()).await.unwrap();
226
    assert_eq!(v.findings.len(), 2, "both are reported: {:?}", v.findings);
227
}