Skip to main content

wire/
enroll.rs

1//! RFC-001 — operator / organization enrollment (producer side).
2//!
3//! The verifier side (`org_membership`, `pair_decision`, `org_policy`) consumes
4//! identity claims; this is the half that *produces* them. Pure over the
5//! supplied keypairs — key STORAGE (where the operator's / org's private keys
6//! live on disk) is the CLI's concern, deliberately not here, so this stays
7//! unit-testable and reusable by the CLI, the live agent, and the e2e alike.
8//!
9//! Two operations:
10//!  - an **org issues a membership cert** for an operator (`issue_member_cert`):
11//!    the org key signs the operator's `op_did`;
12//!  - an **operator assembles its session claims** (`build_member_claims`):
13//!    signs `op_cert` over the session DID and carries `op_pubkey` + each org's
14//!    pubkey inline so the resulting card verifies fully offline (#94).
15
16use crate::agent_card::{IdentityClaims, OrgMembership, did_for_op};
17use crate::identity::{CertError, sign_did_cert};
18use crate::signing::b64encode;
19
20/// One org membership an operator holds, ready to assemble into card claims.
21/// `member_cert` is produced by the org via [`issue_member_cert`].
22pub struct MemberOf {
23    pub org_did: String,
24    pub org_pubkey: [u8; 32],
25    pub member_cert: String,
26}
27
28/// An org issues a membership cert for an operator: the org's key signs the
29/// operator's `op_did` (UTF-8 bytes). The operator carries the returned base64
30/// cert in its card; a receiver verifies it with `identity::verify_member_cert`
31/// against the inline `org_pubkey`.
32pub fn issue_member_cert(org_sk: &[u8], op_did: &str) -> Result<String, CertError> {
33    sign_did_cert(org_sk, op_did)
34}
35
36/// Assemble the v3.2 [`IdentityClaims`] a session presents.
37///
38/// Given the operator's handle + keypair, the session DID this card belongs to,
39/// and the operator's org memberships, this signs `op_cert` over the session
40/// DID and carries `op_pubkey` + each membership's `org_pubkey` inline. The
41/// resulting claims, layered via `agent_card::with_identity_claims` and signed,
42/// verify fully offline through `org_membership::evaluate_card_membership`.
43pub fn build_member_claims(
44    op_handle: &str,
45    op_sk: &[u8; 32],
46    op_pk: &[u8; 32],
47    session_did: &str,
48    memberships: &[MemberOf],
49    project: Option<String>,
50) -> Result<IdentityClaims, CertError> {
51    let op_did = did_for_op(op_handle, op_pk);
52    let op_cert = sign_did_cert(op_sk, session_did)?;
53    let org_memberships = memberships
54        .iter()
55        .map(|m| OrgMembership {
56            org_did: m.org_did.clone(),
57            org_pubkey: b64encode(&m.org_pubkey),
58            member_cert: m.member_cert.clone(),
59        })
60        .collect();
61    Ok(IdentityClaims {
62        op_did: Some(op_did),
63        op_cert: Some(op_cert),
64        op_pubkey: Some(b64encode(op_pk)),
65        org_memberships,
66        project,
67    })
68}
69
70/// Card-emit (RFC-001 Phase 1b): if this machine has an enrolled operator
71/// (`op.key` present), attach the operator's identity claims + stored org
72/// memberships to `card`. Returns the card unchanged when not enrolled, so
73/// card-build stays correct for the common case. The returned card is UNSIGNED;
74/// the caller signs it (`sign_agent_card`). Malformed stored memberships are
75/// skipped, not fatal.
76pub fn with_op_claims_if_enrolled(
77    card: crate::agent_card::AgentCard,
78) -> anyhow::Result<crate::agent_card::AgentCard> {
79    let Ok(op_sk) = crate::config::read_op_key() else {
80        return Ok(card); // not enrolled → no claims
81    };
82    let session_did = card
83        .get("did")
84        .and_then(|v| v.as_str())
85        .unwrap_or_default()
86        .to_string();
87    if session_did.is_empty() {
88        return Ok(card);
89    }
90    let op_handle = crate::config::read_op_handle()
91        .ok()
92        .flatten()
93        .unwrap_or_else(|| "operator".to_string());
94    let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
95        .verifying_key()
96        .to_bytes();
97
98    let mut memberships = Vec::new();
99    for m in crate::config::read_memberships().unwrap_or_default() {
100        let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
101            m.get("org_did").and_then(|v| v.as_str()),
102            m.get("org_pubkey").and_then(|v| v.as_str()),
103            m.get("member_cert").and_then(|v| v.as_str()),
104        ) else {
105            continue;
106        };
107        let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
108            continue;
109        };
110        if bytes.len() != 32 {
111            continue;
112        }
113        let mut org_pk = [0u8; 32];
114        org_pk.copy_from_slice(&bytes);
115        memberships.push(MemberOf {
116            org_did: org_did.to_string(),
117            org_pubkey: org_pk,
118            member_cert: member_cert.to_string(),
119        });
120    }
121
122    let project = card
123        .get("project")
124        .and_then(|v| v.as_str())
125        .map(str::to_string);
126    // Fail-soft: a cert-build / attach error degrades to "no claims" rather than
127    // breaking card-build (init/up is critical-path; a broken identity config
128    // must never stop a basic agent from coming up).
129    let claims = match build_member_claims(
130        &op_handle,
131        &op_sk,
132        &op_pk,
133        &session_did,
134        &memberships,
135        project,
136    ) {
137        Ok(c) => c,
138        Err(e) => {
139            eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
140            return Ok(card);
141        }
142    };
143    match crate::agent_card::with_identity_claims(&card, &claims) {
144        Ok(c) => Ok(c),
145        Err(e) => {
146            eprintln!("wire: op-claims skipped (attach failed: {e:?})");
147            Ok(card)
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::agent_card::{
156        build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
157    };
158    use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
159    use crate::signing::generate_keypair;
160
161    #[test]
162    fn with_op_claims_attaches_when_enrolled() {
163        crate::config::test_support::with_temp_home(|| {
164            let (op_sk, op_pk) = generate_keypair();
165            crate::config::write_op_key(&op_sk).unwrap();
166            crate::config::write_op_handle("darby").unwrap();
167            let op_did = did_for_op("darby", &op_pk);
168
169            let (org_sk, org_pk) = generate_keypair();
170            let org_did = did_for_org("slanchaai", &org_pk);
171            let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
172            crate::config::add_membership(
173                &org_did,
174                &crate::signing::b64encode(&org_pk),
175                &member_cert,
176            )
177            .unwrap();
178
179            let (_sess_sk, sess_pk) = generate_keypair();
180            let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
181            let with = with_op_claims_if_enrolled(base).unwrap();
182            assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
183            assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
184        });
185    }
186
187    #[test]
188    fn with_op_claims_noop_when_not_enrolled() {
189        crate::config::test_support::with_temp_home(|| {
190            let (_sk, pk) = generate_keypair();
191            let base = build_agent_card("plain", &pk, None, None, None);
192            let out = with_op_claims_if_enrolled(base.clone()).unwrap();
193            assert_eq!(out, base); // unchanged — not enrolled
194            assert_eq!(crate::agent_card::card_op_did(&out), None);
195        });
196    }
197
198    #[test]
199    fn with_op_claims_failsoft_on_corrupt_memberships() {
200        crate::config::test_support::with_temp_home(|| {
201            let (op_sk, _op_pk) = generate_keypair();
202            crate::config::write_op_key(&op_sk).unwrap(); // creates config dir
203            crate::config::write_op_handle("darby").unwrap();
204            // Corrupt the memberships store — must NOT break card-build.
205            std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
206
207            let (_s, pk) = generate_keypair();
208            let base = build_agent_card("vesper-valley", &pk, None, None, None);
209            // Degrades to op-claim-only (no orgs), never errors.
210            let out = with_op_claims_if_enrolled(base).unwrap();
211            assert!(crate::agent_card::card_op_did(&out).is_some());
212            assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
213        });
214    }
215
216    /// Producer → consumer round-trip: claims built here verify on the other side.
217    #[test]
218    fn built_claims_verify_offline() {
219        let (op_sk, op_pk) = generate_keypair();
220        let (org_sk, org_pk) = generate_keypair();
221        let (sess_sk, sess_pk) = generate_keypair();
222
223        let op_did = did_for_op("darby", &op_pk);
224        let org_did = did_for_org("slanchaai", &org_pk);
225        let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
226
227        let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
228        let session_did = base
229            .get("did")
230            .and_then(|v| v.as_str())
231            .unwrap()
232            .to_string();
233
234        let claims = build_member_claims(
235            "darby",
236            &op_sk,
237            &op_pk,
238            &session_did,
239            &[MemberOf {
240                org_did: org_did.clone(),
241                org_pubkey: org_pk,
242                member_cert,
243            }],
244            Some("print-shop".into()),
245        )
246        .unwrap();
247
248        let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
249        verify_agent_card(&card).unwrap();
250        assert_eq!(
251            evaluate_card_membership(&card),
252            MembershipOutcome::Verified {
253                op_did,
254                org_dids: vec![org_did]
255            }
256        );
257    }
258
259    /// An operator with no org memberships still produces a well-formed op claim
260    /// (op_did/op_cert/op_pubkey) — it just won't reach ORG_VERIFIED (no vouch).
261    #[test]
262    fn operator_without_org_builds_but_is_not_verified() {
263        let (op_sk, op_pk) = generate_keypair();
264        let (sess_sk, sess_pk) = generate_keypair();
265        let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
266        let session_did = base
267            .get("did")
268            .and_then(|v| v.as_str())
269            .unwrap()
270            .to_string();
271
272        let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
273        assert!(claims.op_did.is_some());
274        assert!(claims.op_cert.is_some());
275        assert!(claims.op_pubkey.is_some());
276        assert!(claims.org_memberships.is_empty());
277
278        let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
279        // No org vouch → Rejected (no membership verified), never ORG_VERIFIED.
280        assert!(matches!(
281            evaluate_card_membership(&card),
282            MembershipOutcome::Rejected { .. }
283        ));
284    }
285}