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    with_op_claims_if_enrolled_inner(card)
80}
81
82/// Rebuild the on-disk agent card with the **current** enrollment state and
83/// re-sign it. Closes the enroll-after-`init` DX gap: claims are normally
84/// attached at card-build time (`pair_session::init_self` / `cli.rs` init via
85/// [`with_op_claims_if_enrolled`]), but an operator who enrolls AFTER `init`
86/// has a stored card that pre-dates the claims. This reads the stored card,
87/// strips any pre-existing identity-claim fields + signature, overlays the
88/// current claims via the same helper used at init, re-signs with the existing
89/// session key, and writes the card back. **Pure rebuild** — does NOT publish;
90/// callers (the `wire enroll republish` CLI dispatcher) chain the existing
91/// `republish_card_to_phonebook` to push to the phonebook. Bails if `wire init`
92/// hasn't run; idempotent when not enrolled (strips stale claims → identical to
93/// a freshly-init'd non-enrolled card → re-signed → written).
94pub fn rebuild_card_with_current_claims() -> anyhow::Result<crate::agent_card::AgentCard> {
95    use anyhow::Context;
96    let mut card = crate::config::read_agent_card()
97        .context("no stored agent card — run `wire init` before `wire enroll republish`")?;
98    if let Some(obj) = card.as_object_mut() {
99        // Strip any pre-existing identity claims + the old self-signature so
100        // the rebuilt card is constructed exactly as init would have built it
101        // for the current enrollment state (no stale claims survive).
102        obj.remove("op_did");
103        obj.remove("op_cert");
104        obj.remove("op_pubkey");
105        obj.remove("org_memberships");
106        obj.remove("signature");
107    }
108    let card = with_op_claims_if_enrolled_inner(card)?;
109    let sk = crate::config::read_private_key()
110        .context("no session signing key on disk — re-run `wire init`")?;
111    let signed = crate::agent_card::sign_agent_card(&card, &sk);
112    crate::config::write_agent_card(&signed)?;
113    Ok(signed)
114}
115
116fn with_op_claims_if_enrolled_inner(
117    card: crate::agent_card::AgentCard,
118) -> anyhow::Result<crate::agent_card::AgentCard> {
119    let Ok(op_sk) = crate::config::read_op_key() else {
120        return Ok(card); // not enrolled → no claims
121    };
122    let session_did = card
123        .get("did")
124        .and_then(|v| v.as_str())
125        .unwrap_or_default()
126        .to_string();
127    if session_did.is_empty() {
128        return Ok(card);
129    }
130    let op_handle = crate::config::read_op_handle()
131        .ok()
132        .flatten()
133        .unwrap_or_else(|| "operator".to_string());
134    let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
135        .verifying_key()
136        .to_bytes();
137
138    let mut memberships = Vec::new();
139    for m in crate::config::read_memberships().unwrap_or_default() {
140        let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
141            m.get("org_did").and_then(|v| v.as_str()),
142            m.get("org_pubkey").and_then(|v| v.as_str()),
143            m.get("member_cert").and_then(|v| v.as_str()),
144        ) else {
145            continue;
146        };
147        let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
148            continue;
149        };
150        if bytes.len() != 32 {
151            continue;
152        }
153        let mut org_pk = [0u8; 32];
154        org_pk.copy_from_slice(&bytes);
155        memberships.push(MemberOf {
156            org_did: org_did.to_string(),
157            org_pubkey: org_pk,
158            member_cert: member_cert.to_string(),
159        });
160    }
161
162    let project = card
163        .get("project")
164        .and_then(|v| v.as_str())
165        .map(str::to_string);
166    // Fail-soft: a cert-build / attach error degrades to "no claims" rather than
167    // breaking card-build (init/up is critical-path; a broken identity config
168    // must never stop a basic agent from coming up).
169    let claims = match build_member_claims(
170        &op_handle,
171        &op_sk,
172        &op_pk,
173        &session_did,
174        &memberships,
175        project,
176    ) {
177        Ok(c) => c,
178        Err(e) => {
179            eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
180            return Ok(card);
181        }
182    };
183    match crate::agent_card::with_identity_claims(&card, &claims) {
184        Ok(c) => Ok(c),
185        Err(e) => {
186            eprintln!("wire: op-claims skipped (attach failed: {e:?})");
187            Ok(card)
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::agent_card::{
196        build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
197    };
198    use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
199    use crate::signing::generate_keypair;
200
201    #[test]
202    fn with_op_claims_attaches_when_enrolled() {
203        crate::config::test_support::with_temp_home(|| {
204            let (op_sk, op_pk) = generate_keypair();
205            crate::config::write_op_key(&op_sk).unwrap();
206            crate::config::write_op_handle("darby").unwrap();
207            let op_did = did_for_op("darby", &op_pk);
208
209            let (org_sk, org_pk) = generate_keypair();
210            let org_did = did_for_org("slanchaai", &org_pk);
211            let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
212            crate::config::add_membership(
213                &org_did,
214                &crate::signing::b64encode(&org_pk),
215                &member_cert,
216            )
217            .unwrap();
218
219            let (_sess_sk, sess_pk) = generate_keypair();
220            let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
221            let with = with_op_claims_if_enrolled(base).unwrap();
222            assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
223            assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
224        });
225    }
226
227    #[test]
228    fn with_op_claims_noop_when_not_enrolled() {
229        crate::config::test_support::with_temp_home(|| {
230            let (_sk, pk) = generate_keypair();
231            let base = build_agent_card("plain", &pk, None, None, None);
232            let out = with_op_claims_if_enrolled(base.clone()).unwrap();
233            assert_eq!(out, base); // unchanged — not enrolled
234            assert_eq!(crate::agent_card::card_op_did(&out), None);
235        });
236    }
237
238    #[test]
239    fn with_op_claims_failsoft_on_corrupt_memberships() {
240        crate::config::test_support::with_temp_home(|| {
241            let (op_sk, _op_pk) = generate_keypair();
242            crate::config::write_op_key(&op_sk).unwrap(); // creates config dir
243            crate::config::write_op_handle("darby").unwrap();
244            // Corrupt the memberships store — must NOT break card-build.
245            std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
246
247            let (_s, pk) = generate_keypair();
248            let base = build_agent_card("vesper-valley", &pk, None, None, None);
249            // Degrades to op-claim-only (no orgs), never errors.
250            let out = with_op_claims_if_enrolled(base).unwrap();
251            assert!(crate::agent_card::card_op_did(&out).is_some());
252            assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
253        });
254    }
255
256    /// Producer → consumer round-trip: claims built here verify on the other side.
257    #[test]
258    fn built_claims_verify_offline() {
259        let (op_sk, op_pk) = generate_keypair();
260        let (org_sk, org_pk) = generate_keypair();
261        let (sess_sk, sess_pk) = generate_keypair();
262
263        let op_did = did_for_op("darby", &op_pk);
264        let org_did = did_for_org("slanchaai", &org_pk);
265        let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
266
267        let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
268        let session_did = base
269            .get("did")
270            .and_then(|v| v.as_str())
271            .unwrap()
272            .to_string();
273
274        let claims = build_member_claims(
275            "darby",
276            &op_sk,
277            &op_pk,
278            &session_did,
279            &[MemberOf {
280                org_did: org_did.clone(),
281                org_pubkey: org_pk,
282                member_cert,
283            }],
284            Some("print-shop".into()),
285        )
286        .unwrap();
287
288        let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
289        verify_agent_card(&card).unwrap();
290        assert_eq!(
291            evaluate_card_membership(&card),
292            MembershipOutcome::Verified {
293                op_did,
294                org_dids: vec![org_did]
295            }
296        );
297    }
298
299    /// An operator with no org memberships still produces a well-formed op claim
300    /// (op_did/op_cert/op_pubkey) — it just won't reach ORG_VERIFIED (no vouch).
301    #[test]
302    fn operator_without_org_builds_but_is_not_verified() {
303        let (op_sk, op_pk) = generate_keypair();
304        let (sess_sk, sess_pk) = generate_keypair();
305        let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
306        let session_did = base
307            .get("did")
308            .and_then(|v| v.as_str())
309            .unwrap()
310            .to_string();
311
312        let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
313        assert!(claims.op_did.is_some());
314        assert!(claims.op_cert.is_some());
315        assert!(claims.op_pubkey.is_some());
316        assert!(claims.org_memberships.is_empty());
317
318        let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
319        // No org vouch → Rejected (no membership verified), never ORG_VERIFIED.
320        assert!(matches!(
321            evaluate_card_membership(&card),
322            MembershipOutcome::Rejected { .. }
323        ));
324    }
325
326    /// The DX-gap fix: an operator who enrolls AFTER `wire init` can run
327    /// `wire enroll republish` to pick up their fresh claims without a re-init.
328    #[test]
329    fn rebuild_picks_up_post_init_enrollment() {
330        crate::config::test_support::with_temp_home(|| {
331            std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
332            // Simulate `wire init`: write a session key + a stored card without claims.
333            let (sess_sk, sess_pk) = generate_keypair();
334            crate::config::write_private_key(&sess_sk).unwrap();
335            let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
336            crate::config::write_agent_card(&sign_agent_card(&base, &sess_sk)).unwrap();
337            assert_eq!(
338                crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap()),
339                None
340            );
341
342            // Operator enrolls AFTER init.
343            let (op_sk, op_pk) = generate_keypair();
344            crate::config::write_op_key(&op_sk).unwrap();
345            crate::config::write_op_handle("darby").unwrap();
346            let op_did = crate::agent_card::did_for_op("darby", &op_pk);
347            let (org_sk, org_pk) = generate_keypair();
348            let org_did = did_for_org("slanchaai", &org_pk);
349            let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
350            crate::config::add_membership(
351                &org_did,
352                &crate::signing::b64encode(&org_pk),
353                &member_cert,
354            )
355            .unwrap();
356
357            // Republish rebuild — picks up the post-init claims.
358            let signed = rebuild_card_with_current_claims().unwrap();
359            verify_agent_card(&signed).unwrap();
360            assert_eq!(
361                crate::agent_card::card_op_did(&signed),
362                Some(op_did.as_str())
363            );
364            assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 1);
365            // The new card is what's on disk now.
366            let on_disk = crate::config::read_agent_card().unwrap();
367            assert_eq!(on_disk, signed);
368        });
369    }
370
371    /// Idempotent / fail-soft: not-enrolled stays not-enrolled, AND any stale
372    /// claims on the on-disk card get stripped (the as-current invariant).
373    #[test]
374    fn rebuild_strips_stale_claims_when_unenrolled() {
375        crate::config::test_support::with_temp_home(|| {
376            std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
377            let (sess_sk, sess_pk) = generate_keypair();
378            crate::config::write_private_key(&sess_sk).unwrap();
379
380            // Manufacture a card with stale claims (as if the operator was once
381            // enrolled, ran republish, then later un-enrolled by removing op.key).
382            let (op_sk, op_pk) = generate_keypair();
383            let op_did = crate::agent_card::did_for_op("darby", &op_pk);
384            let (org_sk, org_pk) = generate_keypair();
385            let org_did = did_for_org("slanchaai", &org_pk);
386            let stale = with_identity_claims(
387                &build_agent_card("vesper-valley", &sess_pk, None, None, None),
388                &IdentityClaims {
389                    op_did: Some(op_did.clone()),
390                    op_cert: Some(crate::identity::sign_did_cert(&op_sk, &op_did).unwrap()),
391                    op_pubkey: Some(crate::signing::b64encode(&op_pk)),
392                    org_memberships: vec![OrgMembership {
393                        org_did,
394                        org_pubkey: crate::signing::b64encode(&org_pk),
395                        member_cert: issue_member_cert(&org_sk, &op_did).unwrap(),
396                    }],
397                    project: None,
398                },
399            )
400            .unwrap();
401            crate::config::write_agent_card(&sign_agent_card(&stale, &sess_sk)).unwrap();
402            assert!(
403                crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap())
404                    .is_some()
405            );
406
407            // No op.key on disk → not enrolled → rebuild strips the stale claims.
408            let signed = rebuild_card_with_current_claims().unwrap();
409            verify_agent_card(&signed).unwrap();
410            assert_eq!(crate::agent_card::card_op_did(&signed), None);
411            assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 0);
412        });
413    }
414
415    /// No `wire init` → no stored card → clear error (not a panic).
416    #[test]
417    fn rebuild_bails_without_init() {
418        crate::config::test_support::with_temp_home(|| {
419            let err = rebuild_card_with_current_claims().unwrap_err();
420            let msg = format!("{err:?}");
421            assert!(
422                msg.contains("agent card") || msg.contains("init"),
423                "got: {msg}"
424            );
425        });
426    }
427}