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        // Attached separately by `with_op_claims_if_enrolled_inner` (needs the
68        // local machine fingerprint, which is I/O, not pure key material).
69        same_machine_attestation: None,
70    })
71}
72
73/// Card-emit (RFC-001 Phase 1b): if this machine has an enrolled operator
74/// (`op.key` present), attach the operator's identity claims + stored org
75/// memberships to `card`. Returns the card unchanged when not enrolled, so
76/// card-build stays correct for the common case. The returned card is UNSIGNED;
77/// the caller signs it (`sign_agent_card`). Malformed stored memberships are
78/// skipped, not fatal.
79pub fn with_op_claims_if_enrolled(
80    card: crate::agent_card::AgentCard,
81) -> anyhow::Result<crate::agent_card::AgentCard> {
82    with_op_claims_if_enrolled_inner(card)
83}
84
85/// Rebuild the on-disk agent card with the **current** enrollment state and
86/// re-sign it. Closes the enroll-after-`init` DX gap: claims are normally
87/// attached at card-build time (`init::init_self_idempotent` / `cli.rs` init via
88/// [`with_op_claims_if_enrolled`]), but an operator who enrolls AFTER `init`
89/// has a stored card that pre-dates the claims. This reads the stored card,
90/// strips any pre-existing identity-claim fields + signature, overlays the
91/// current claims via the same helper used at init, re-signs with the existing
92/// session key, and writes the card back. **Pure rebuild** — does NOT publish;
93/// callers (the `wire enroll republish` CLI dispatcher) chain the existing
94/// `republish_card_to_phonebook` to push to the phonebook. Bails if `wire init`
95/// hasn't run; idempotent when not enrolled (strips stale claims → identical to
96/// a freshly-init'd non-enrolled card → re-signed → written).
97pub fn rebuild_card_with_current_claims() -> anyhow::Result<crate::agent_card::AgentCard> {
98    use anyhow::Context;
99    let mut card = crate::config::read_agent_card()
100        .context("no stored agent card — run `wire init` before `wire enroll republish`")?;
101    if let Some(obj) = card.as_object_mut() {
102        // Strip any pre-existing identity claims + the old self-signature so
103        // the rebuilt card is constructed exactly as init would have built it
104        // for the current enrollment state (no stale claims survive).
105        obj.remove("op_did");
106        obj.remove("op_cert");
107        obj.remove("op_pubkey");
108        obj.remove("org_memberships");
109        obj.remove("same_machine_attestation"); // #182: rebuilt fresh below
110        obj.remove("nostr_pubkey"); // RFC-007 D3.1: rebuilt fresh below
111        obj.remove("signature");
112
113        // v0.14.2 (#126): refresh the wire/* entry in `capabilities[]` so
114        // the republished card advertises the binary's current
115        // CARD_SCHEMA_VERSION. Pre-fix: an operator who init'd at
116        // v0.13.5 (capabilities=["wire/v3.1"]) and republished at
117        // v0.14.1 kept the old "wire/v3.1" entry even though
118        // schema_version bumped to v3.2 — peers gating on the
119        // capabilities set silently bypassed upgraded sessions. Only
120        // wire/* entries are binary-derived; operator-defined caps
121        // (e.g. custom task tags, future feature flags) are preserved.
122        let current_wire_cap = format!("wire/{}", crate::agent_card::CARD_SCHEMA_VERSION);
123        let preserved_caps: Vec<serde_json::Value> = obj
124            .get("capabilities")
125            .and_then(serde_json::Value::as_array)
126            .map(|arr| {
127                arr.iter()
128                    .filter(|c| c.as_str().map(|s| !s.starts_with("wire/")).unwrap_or(false))
129                    .cloned()
130                    .collect()
131            })
132            .unwrap_or_default();
133        let mut new_caps = vec![serde_json::Value::String(current_wire_cap)];
134        new_caps.extend(preserved_caps);
135        obj.insert("capabilities".into(), serde_json::Value::Array(new_caps));
136    }
137    let card = with_op_claims_if_enrolled_inner(card)?;
138    // RFC-007 D3.1: (re)attach the Nostr transport binding so `wire enroll
139    // republish` surfaces a transport key minted after init. Fail-soft no-op
140    // when not keyed.
141    let card = crate::nostr_key::with_nostr_binding_if_keyed(card)?;
142    let sk = crate::config::read_private_key()
143        .context("no session signing key on disk — re-run `wire init`")?;
144    let signed = crate::agent_card::sign_agent_card(&card, &sk);
145    crate::config::write_agent_card(&signed)?;
146    Ok(signed)
147}
148
149fn with_op_claims_if_enrolled_inner(
150    card: crate::agent_card::AgentCard,
151) -> anyhow::Result<crate::agent_card::AgentCard> {
152    let Ok(op_sk) = crate::config::read_op_key() else {
153        return Ok(card); // not enrolled → no claims
154    };
155    let session_did = card
156        .get("did")
157        .and_then(|v| v.as_str())
158        .unwrap_or_default()
159        .to_string();
160    if session_did.is_empty() {
161        return Ok(card);
162    }
163    let op_handle = crate::config::read_op_handle()
164        .ok()
165        .flatten()
166        .unwrap_or_else(|| "operator".to_string());
167    let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
168        .verifying_key()
169        .to_bytes();
170
171    let mut memberships = Vec::new();
172    for m in crate::config::read_memberships().unwrap_or_default() {
173        let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
174            m.get("org_did").and_then(|v| v.as_str()),
175            m.get("org_pubkey").and_then(|v| v.as_str()),
176            m.get("member_cert").and_then(|v| v.as_str()),
177        ) else {
178            continue;
179        };
180        let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
181            continue;
182        };
183        if bytes.len() != 32 {
184            continue;
185        }
186        let mut org_pk = [0u8; 32];
187        org_pk.copy_from_slice(&bytes);
188        memberships.push(MemberOf {
189            org_did: org_did.to_string(),
190            org_pubkey: org_pk,
191            member_cert: member_cert.to_string(),
192        });
193    }
194
195    let project = card
196        .get("project")
197        .and_then(|v| v.as_str())
198        .map(str::to_string);
199    // Fail-soft: a cert-build / attach error degrades to "no claims" rather than
200    // breaking card-build (init/up is critical-path; a broken identity config
201    // must never stop a basic agent from coming up).
202    let mut claims = match build_member_claims(
203        &op_handle,
204        &op_sk,
205        &op_pk,
206        &session_did,
207        &memberships,
208        project,
209    ) {
210        Ok(c) => c,
211        Err(e) => {
212            eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
213            return Ok(card);
214        }
215    };
216
217    // RFC-001 amendment #182: attach a same-machine attestation when the local
218    // machine fingerprint is readable. Op-enrolled (we have op_sk) + same disk
219    // ⇒ a receiver on this same (machine, OS user) can auto-pin us at
220    // ORG_VERIFIED. Fail-soft: an unreadable fingerprint just omits the field.
221    if let Some(fp) = crate::same_machine::local_fingerprint() {
222        match crate::same_machine::build_attestation(&op_sk, &fp, &session_did) {
223            Ok(att) => claims.same_machine_attestation = Some(att),
224            Err(e) => eprintln!("wire: same-machine attestation skipped (sign failed: {e:?})"),
225        }
226    }
227    match crate::agent_card::with_identity_claims(&card, &claims) {
228        Ok(c) => Ok(c),
229        Err(e) => {
230            eprintln!("wire: op-claims skipped (attach failed: {e:?})");
231            Ok(card)
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::agent_card::{
240        build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
241    };
242    use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
243    use crate::signing::generate_keypair;
244
245    #[test]
246    fn with_op_claims_attaches_when_enrolled() {
247        crate::config::test_support::with_temp_home(|| {
248            let (op_sk, op_pk) = generate_keypair();
249            crate::config::write_op_key(&op_sk).unwrap();
250            crate::config::write_op_handle("darby").unwrap();
251            let op_did = did_for_op("darby", &op_pk);
252
253            let (org_sk, org_pk) = generate_keypair();
254            let org_did = did_for_org("slanchaai", &org_pk);
255            let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
256            crate::config::add_membership(
257                &org_did,
258                &crate::signing::b64encode(&org_pk),
259                &member_cert,
260            )
261            .unwrap();
262
263            let (_sess_sk, sess_pk) = generate_keypair();
264            let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
265            let with = with_op_claims_if_enrolled(base).unwrap();
266            assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
267            assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
268        });
269    }
270
271    #[test]
272    fn with_op_claims_noop_when_not_enrolled() {
273        crate::config::test_support::with_temp_home(|| {
274            let (_sk, pk) = generate_keypair();
275            let base = build_agent_card("plain", &pk, None, None, None);
276            let out = with_op_claims_if_enrolled(base.clone()).unwrap();
277            assert_eq!(out, base); // unchanged — not enrolled
278            assert_eq!(crate::agent_card::card_op_did(&out), None);
279        });
280    }
281
282    #[test]
283    fn with_op_claims_failsoft_on_corrupt_memberships() {
284        crate::config::test_support::with_temp_home(|| {
285            let (op_sk, _op_pk) = generate_keypair();
286            crate::config::write_op_key(&op_sk).unwrap(); // creates config dir
287            crate::config::write_op_handle("darby").unwrap();
288            // Corrupt the memberships store — must NOT break card-build.
289            std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
290
291            let (_s, pk) = generate_keypair();
292            let base = build_agent_card("vesper-valley", &pk, None, None, None);
293            // Degrades to op-claim-only (no orgs), never errors.
294            let out = with_op_claims_if_enrolled(base).unwrap();
295            assert!(crate::agent_card::card_op_did(&out).is_some());
296            assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
297        });
298    }
299
300    /// Producer → consumer round-trip: claims built here verify on the other side.
301    #[test]
302    fn built_claims_verify_offline() {
303        let (op_sk, op_pk) = generate_keypair();
304        let (org_sk, org_pk) = generate_keypair();
305        let (sess_sk, sess_pk) = generate_keypair();
306
307        let op_did = did_for_op("darby", &op_pk);
308        let org_did = did_for_org("slanchaai", &org_pk);
309        let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
310
311        let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
312        let session_did = base
313            .get("did")
314            .and_then(|v| v.as_str())
315            .unwrap()
316            .to_string();
317
318        let claims = build_member_claims(
319            "darby",
320            &op_sk,
321            &op_pk,
322            &session_did,
323            &[MemberOf {
324                org_did: org_did.clone(),
325                org_pubkey: org_pk,
326                member_cert,
327            }],
328            Some("print-shop".into()),
329        )
330        .unwrap();
331
332        let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
333        verify_agent_card(&card).unwrap();
334        assert_eq!(
335            evaluate_card_membership(&card),
336            MembershipOutcome::Verified {
337                op_did,
338                org_dids: vec![org_did]
339            }
340        );
341    }
342
343    /// An operator with no org memberships still produces a well-formed op claim
344    /// (op_did/op_cert/op_pubkey) — it just won't reach ORG_VERIFIED (no vouch).
345    #[test]
346    fn operator_without_org_builds_but_is_not_verified() {
347        let (op_sk, op_pk) = generate_keypair();
348        let (sess_sk, sess_pk) = generate_keypair();
349        let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
350        let session_did = base
351            .get("did")
352            .and_then(|v| v.as_str())
353            .unwrap()
354            .to_string();
355
356        let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
357        assert!(claims.op_did.is_some());
358        assert!(claims.op_cert.is_some());
359        assert!(claims.op_pubkey.is_some());
360        assert!(claims.org_memberships.is_empty());
361
362        let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
363        // No org vouch → Rejected (no membership verified), never ORG_VERIFIED.
364        assert!(matches!(
365            evaluate_card_membership(&card),
366            MembershipOutcome::Rejected { .. }
367        ));
368    }
369
370    /// The DX-gap fix: an operator who enrolls AFTER `wire init` can run
371    /// `wire enroll republish` to pick up their fresh claims without a re-init.
372    #[test]
373    fn rebuild_picks_up_post_init_enrollment() {
374        crate::config::test_support::with_temp_home(|| {
375            std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
376            // Simulate `wire init`: write a session key + a stored card without claims.
377            let (sess_sk, sess_pk) = generate_keypair();
378            crate::config::write_private_key(&sess_sk).unwrap();
379            let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
380            crate::config::write_agent_card(&sign_agent_card(&base, &sess_sk)).unwrap();
381            assert_eq!(
382                crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap()),
383                None
384            );
385
386            // Operator enrolls AFTER init.
387            let (op_sk, op_pk) = generate_keypair();
388            crate::config::write_op_key(&op_sk).unwrap();
389            crate::config::write_op_handle("darby").unwrap();
390            let op_did = crate::agent_card::did_for_op("darby", &op_pk);
391            let (org_sk, org_pk) = generate_keypair();
392            let org_did = did_for_org("slanchaai", &org_pk);
393            let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
394            crate::config::add_membership(
395                &org_did,
396                &crate::signing::b64encode(&org_pk),
397                &member_cert,
398            )
399            .unwrap();
400
401            // Republish rebuild — picks up the post-init claims.
402            let signed = rebuild_card_with_current_claims().unwrap();
403            verify_agent_card(&signed).unwrap();
404            assert_eq!(
405                crate::agent_card::card_op_did(&signed),
406                Some(op_did.as_str())
407            );
408            assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 1);
409            // The new card is what's on disk now.
410            let on_disk = crate::config::read_agent_card().unwrap();
411            assert_eq!(on_disk, signed);
412        });
413    }
414
415    /// Idempotent / fail-soft: not-enrolled stays not-enrolled, AND any stale
416    /// claims on the on-disk card get stripped (the as-current invariant).
417    #[test]
418    fn rebuild_strips_stale_claims_when_unenrolled() {
419        crate::config::test_support::with_temp_home(|| {
420            std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
421            let (sess_sk, sess_pk) = generate_keypair();
422            crate::config::write_private_key(&sess_sk).unwrap();
423
424            // Manufacture a card with stale claims (as if the operator was once
425            // enrolled, ran republish, then later un-enrolled by removing op.key).
426            let (op_sk, op_pk) = generate_keypair();
427            let op_did = crate::agent_card::did_for_op("darby", &op_pk);
428            let (org_sk, org_pk) = generate_keypair();
429            let org_did = did_for_org("slanchaai", &org_pk);
430            let stale = with_identity_claims(
431                &build_agent_card("vesper-valley", &sess_pk, None, None, None),
432                &IdentityClaims {
433                    op_did: Some(op_did.clone()),
434                    op_cert: Some(crate::identity::sign_did_cert(&op_sk, &op_did).unwrap()),
435                    op_pubkey: Some(crate::signing::b64encode(&op_pk)),
436                    org_memberships: vec![OrgMembership {
437                        org_did,
438                        org_pubkey: crate::signing::b64encode(&org_pk),
439                        member_cert: issue_member_cert(&org_sk, &op_did).unwrap(),
440                    }],
441                    project: None,
442                    same_machine_attestation: None,
443                },
444            )
445            .unwrap();
446            crate::config::write_agent_card(&sign_agent_card(&stale, &sess_sk)).unwrap();
447            assert!(
448                crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap())
449                    .is_some()
450            );
451
452            // No op.key on disk → not enrolled → rebuild strips the stale claims.
453            let signed = rebuild_card_with_current_claims().unwrap();
454            verify_agent_card(&signed).unwrap();
455            assert_eq!(crate::agent_card::card_op_did(&signed), None);
456            assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 0);
457        });
458    }
459
460    /// #126 fix: a v0.13.5-era stored card has `capabilities=["wire/v3.1"]`.
461    /// Republish on v0.14.x must refresh the wire/* entry to match the
462    /// binary's current CARD_SCHEMA_VERSION; otherwise peers gating on
463    /// `capabilities` silently bypass upgraded sessions even as
464    /// `schema_version` bumps to v3.2.
465    #[test]
466    fn rebuild_refreshes_wire_capability_to_current() {
467        crate::config::test_support::with_temp_home(|| {
468            std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
469            let (sess_sk, sess_pk) = generate_keypair();
470            crate::config::write_private_key(&sess_sk).unwrap();
471            // Manufacture a stored card with the legacy "wire/v3.1" capability.
472            let legacy = build_agent_card(
473                "slate-lotus",
474                &sess_pk,
475                None,
476                Some(vec!["wire/v3.1".to_string()]),
477                None,
478            );
479            crate::config::write_agent_card(&sign_agent_card(&legacy, &sess_sk)).unwrap();
480            // Sanity: precondition matches the bug honey/slate-lotus reported.
481            let before = crate::config::read_agent_card().unwrap();
482            assert_eq!(
483                before["capabilities"],
484                serde_json::json!(["wire/v3.1"]),
485                "precondition: stored card has legacy capability"
486            );
487
488            // Republish (no claims) — must refresh capabilities[].
489            let signed = rebuild_card_with_current_claims().unwrap();
490            verify_agent_card(&signed).unwrap();
491            assert_eq!(
492                signed["capabilities"],
493                serde_json::json!([format!("wire/{}", crate::agent_card::CARD_SCHEMA_VERSION)]),
494                "republish must refresh wire/* to current CARD_SCHEMA_VERSION"
495            );
496        });
497    }
498
499    /// #126 fix invariant: non-wire/* capabilities are operator-defined and
500    /// MUST survive the republish refresh. Only the wire/* slot is
501    /// binary-derived; custom task tags, feature flags, etc. persist.
502    #[test]
503    fn rebuild_preserves_non_wire_capabilities_through_refresh() {
504        crate::config::test_support::with_temp_home(|| {
505            std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
506            let (sess_sk, sess_pk) = generate_keypair();
507            crate::config::write_private_key(&sess_sk).unwrap();
508            // Mixed caps: legacy wire/* + two operator-defined entries.
509            let mixed = build_agent_card(
510                "slate-lotus",
511                &sess_pk,
512                None,
513                Some(vec![
514                    "wire/v3.1".to_string(),
515                    "custom-tag".to_string(),
516                    "org/v1".to_string(),
517                ]),
518                None,
519            );
520            crate::config::write_agent_card(&sign_agent_card(&mixed, &sess_sk)).unwrap();
521
522            let signed = rebuild_card_with_current_claims().unwrap();
523            verify_agent_card(&signed).unwrap();
524            let caps: Vec<String> = signed["capabilities"]
525                .as_array()
526                .unwrap()
527                .iter()
528                .map(|v| v.as_str().unwrap().to_string())
529                .collect();
530            // Current wire/* prepended, legacy wire/* dropped, others preserved
531            // in their original order.
532            assert_eq!(
533                caps,
534                vec![
535                    format!("wire/{}", crate::agent_card::CARD_SCHEMA_VERSION),
536                    "custom-tag".to_string(),
537                    "org/v1".to_string(),
538                ],
539                "non-wire/* caps must survive the refresh; only wire/* is replaced"
540            );
541        });
542    }
543
544    /// No `wire init` → no stored card → clear error (not a panic).
545    #[test]
546    fn rebuild_bails_without_init() {
547        crate::config::test_support::with_temp_home(|| {
548            let err = rebuild_card_with_current_claims().unwrap_err();
549            let msg = format!("{err:?}");
550            assert!(
551                msg.contains("agent card") || msg.contains("init"),
552                "got: {msg}"
553            );
554        });
555    }
556}