Skip to main content

wire/
org_membership.rs

1//! RFC-001 Phase 1 — evaluate a received agent-card's organizational
2//! membership claims and decide which orgs vouch for the peer.
3//!
4//! Bridges Phase 0's cert verifiers (`identity.rs`, #90) toward the live
5//! pairing path. **The whole membership chain verifies offline** — no network,
6//! no relay endpoint, no did:web on the hot path — because every DID is a hash
7//! commitment to its key (surfaced by a security/systems-design persona
8//! critique, 2026-05-29):
9//!
10//! - `op_did`  = `did:wire:op:<handle>-<32hex sha256(op_pubkey)>`
11//! - `org_did` = `did:wire:org:<handle>-<32hex sha256(org_pubkey)>`
12//!
13//! The card carries `op_pubkey` and each membership's `org_pubkey` inline; we
14//! verify each by recomputing its commitment, then check the cert it anchors.
15//! A peer therefore cannot substitute a key for any DID it claims.
16//!
17//! What this module does NOT do: decide *which* orgs to trust. It returns the
18//! set of `org_did`s that cryptographically vouch for the operator; the caller
19//! (Phase 3 policy) matches those against the receiver's own trusted-org set.
20//! Resolving a domain → `org_did` (DNS-TXT / did:web) is a *policy-setup-time*
21//! convenience (`wire org policy set <domain>`), not a per-pairing dependency —
22//! it belongs to a later phase, not here.
23//!
24//! Invariant (RFC-001 §5): the *most* a verified membership earns is
25//! `ORG_VERIFIED`. Crossing into `VERIFIED` still requires bilateral
26//! SPAKE2+SAS — untouched here.
27
28use crate::agent_card::{self, AgentCard};
29use crate::identity::{verify_member_cert, verify_op_cert};
30use crate::signing::b64decode;
31
32/// Outcome of evaluating a card's organizational claims.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum MembershipOutcome {
35    /// No `op_did` on the card — an ordinary peer; no org easing applies.
36    NoClaim,
37    /// Operator binding AND ≥1 org vouch verified end-to-end. Carries the
38    /// `op_did` and the `org_did`s that checked out (for the Phase 3 filter
39    /// surface + policy match).
40    Verified {
41        op_did: String,
42        org_dids: Vec<String>,
43    },
44    /// A claim was present but failed verification. The caller MUST NOT
45    /// promote — treat the peer as if it had no claim (fail closed) and may
46    /// surface `reason` for diagnostics.
47    Rejected { reason: String },
48}
49
50/// Decode a base64 32-byte Ed25519 key, or `None` if absent/malformed.
51fn key32(v: Option<&serde_json::Value>) -> Option<[u8; 32]> {
52    let bytes = v.and_then(|v| v.as_str()).and_then(|s| b64decode(s).ok())?;
53    if bytes.len() != 32 {
54        return None;
55    }
56    let mut k = [0u8; 32];
57    k.copy_from_slice(&bytes);
58    Some(k)
59}
60
61/// True iff `did` ends with the long-fingerprint commitment of `pubkey` —
62/// i.e. `did` is the DID derived from `pubkey`. This is what makes an inline
63/// key self-certifying against a `did:wire:op:`/`did:wire:org:` identifier.
64fn commits_to(did: &str, pubkey: &[u8; 32]) -> bool {
65    did.ends_with(&format!("-{}", agent_card::long_fingerprint(pubkey)))
66}
67
68/// Verify a received card's organizational claims. Fully offline.
69///
70/// For [`MembershipOutcome::Verified`] all must hold:
71///  1. well-formed `op_did` + inline `op_pubkey` that commits to it, + `op_cert`;
72///  2. `op_cert` verifies — `op_pubkey` signed this card's `did` (session DID).
73///     Closes "claim someone else's `op_did`": no operator identity without
74///     the operator's signature over the session;
75///  3. ≥1 `org_memberships[]` entry with a well-formed `org_did`, an inline
76///     `org_pubkey` that commits to it, and a `member_cert` the org key signed
77///     over `op_did`.
78///
79/// Any membership entry that fails a check is skipped (others may vouch); none
80/// verifying → `Rejected`. A bad `op_pubkey` commitment / `op_cert` is fatal.
81pub fn evaluate_card_membership(card: &AgentCard) -> MembershipOutcome {
82    let op_did = match agent_card::card_op_did(card) {
83        Some(d) => d,
84        None => return MembershipOutcome::NoClaim,
85    };
86
87    let session_did = card.get("did").and_then(|v| v.as_str()).unwrap_or_default();
88    if session_did.is_empty() {
89        return MembershipOutcome::Rejected {
90            reason: "card has no `did` to bind the operator cert to".into(),
91        };
92    }
93    if !agent_card::is_op_did(op_did) {
94        return MembershipOutcome::Rejected {
95            reason: format!("`op_did` slot holds a non-operator DID: {op_did}"),
96        };
97    }
98    let op_pubkey = match key32(card.get("op_pubkey")) {
99        Some(k) => k,
100        None => {
101            return MembershipOutcome::Rejected {
102                reason: "`op_pubkey` missing or not a 32-byte base64 key".into(),
103            };
104        }
105    };
106    if !commits_to(op_did, &op_pubkey) {
107        return MembershipOutcome::Rejected {
108            reason: "`op_pubkey` does not match the `op_did` hash commitment".into(),
109        };
110    }
111    let op_cert = match agent_card::card_op_cert(card) {
112        Some(c) => c,
113        None => {
114            return MembershipOutcome::Rejected {
115                reason: "`op_did` present without an `op_cert` — operator binding unprovable"
116                    .into(),
117            };
118        }
119    };
120    if verify_op_cert(&op_pubkey, op_cert, session_did).is_err() {
121        return MembershipOutcome::Rejected {
122            reason: "`op_cert` does not bind this session to the operator".into(),
123        };
124    }
125
126    // At least one org must vouch for the operator — each entry self-certifying.
127    let mut verified_orgs = Vec::new();
128    if let Some(entries) = card.get("org_memberships").and_then(|v| v.as_array()) {
129        for m in entries {
130            let Some(org_did) = m.get("org_did").and_then(|v| v.as_str()) else {
131                continue;
132            };
133            let Some(member_cert) = m.get("member_cert").and_then(|v| v.as_str()) else {
134                continue;
135            };
136            if !agent_card::is_org_did(org_did) {
137                continue;
138            }
139            let Some(org_pubkey) = key32(m.get("org_pubkey")) else {
140                continue; // no inline org key → can't verify the vouch → skip (fail closed)
141            };
142            if !commits_to(org_did, &org_pubkey) {
143                continue; // inline org key doesn't match the claimed org_did
144            }
145            if verify_member_cert(&org_pubkey, member_cert, op_did).is_ok() {
146                verified_orgs.push(org_did.to_string());
147            }
148        }
149    }
150
151    if verified_orgs.is_empty() {
152        return MembershipOutcome::Rejected {
153            reason: "no `org_memberships[]` entry verified (commitment + member_cert)".into(),
154        };
155    }
156
157    MembershipOutcome::Verified {
158        op_did: op_did.to_string(),
159        org_dids: verified_orgs,
160    }
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166    use crate::agent_card::{did_for_op, did_for_org};
167    use crate::identity::sign_did_cert;
168    use crate::signing::b64encode;
169    use ed25519_dalek::SigningKey;
170    use serde_json::json;
171
172    fn keypair(seed: u8) -> ([u8; 32], [u8; 32]) {
173        let sk = [seed; 32];
174        let pk = SigningKey::from_bytes(&sk).verifying_key().to_bytes();
175        (sk, pk)
176    }
177
178    fn card(
179        session_did: &str,
180        op_did: Option<&str>,
181        op_pubkey: Option<&[u8; 32]>,
182        op_cert: Option<&str>,
183        orgs: &[(&str, Option<&[u8; 32]>, &str)],
184    ) -> AgentCard {
185        let mut c = json!({ "schema_version": "v3.2", "did": session_did, "handle": "peer" });
186        if let Some(o) = op_did {
187            c["op_did"] = json!(o);
188        }
189        if let Some(pk) = op_pubkey {
190            c["op_pubkey"] = json!(b64encode(pk));
191        }
192        if let Some(oc) = op_cert {
193            c["op_cert"] = json!(oc);
194        }
195        if !orgs.is_empty() {
196            c["org_memberships"] = json!(
197                orgs.iter()
198                    .map(|(od, opk, cert)| {
199                        let mut e = json!({"org_did": od, "member_cert": cert});
200                        if let Some(pk) = opk {
201                            e["org_pubkey"] = json!(b64encode(*pk));
202                        }
203                        e
204                    })
205                    .collect::<Vec<_>>()
206            );
207        }
208        c
209    }
210
211    #[test]
212    fn verified_when_offline_chain_checks_out() {
213        let (op_sk, op_pk) = keypair(1);
214        let (org_sk, org_pk) = keypair(2);
215        let op_did = did_for_op("darby", &op_pk);
216        let org_did = did_for_org("slanchaai", &org_pk);
217        let session_did = "did:wire:swift-harbor-4092b577";
218        let op_cert = sign_did_cert(&op_sk, session_did).unwrap();
219        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
220        let c = card(
221            session_did,
222            Some(&op_did),
223            Some(&op_pk),
224            Some(&op_cert),
225            &[(&org_did, Some(&org_pk), &member_cert)],
226        );
227        assert_eq!(
228            evaluate_card_membership(&c),
229            MembershipOutcome::Verified {
230                op_did,
231                org_dids: vec![org_did_for(&org_pk)]
232            }
233        );
234    }
235
236    fn org_did_for(pk: &[u8; 32]) -> String {
237        did_for_org("slanchaai", pk)
238    }
239
240    #[test]
241    fn no_claim_when_no_op_did() {
242        assert_eq!(
243            evaluate_card_membership(&card("did:wire:plain-deadbeef", None, None, None, &[])),
244            MembershipOutcome::NoClaim
245        );
246    }
247
248    #[test]
249    fn rejected_when_op_pubkey_breaks_commitment() {
250        let (_, real_op_pk) = keypair(1);
251        let (_, wrong_pk) = keypair(7);
252        let op_did = did_for_op("darby", &real_op_pk);
253        let c = card(
254            "did:wire:x-1",
255            Some(&op_did),
256            Some(&wrong_pk),
257            Some("AA=="),
258            &[],
259        );
260        assert!(matches!(
261            evaluate_card_membership(&c),
262            MembershipOutcome::Rejected { .. }
263        ));
264    }
265
266    #[test]
267    fn rejected_when_org_pubkey_breaks_commitment() {
268        let (op_sk, op_pk) = keypair(1);
269        let (org_sk, real_org_pk) = keypair(2);
270        let (_, wrong_org_pk) = keypair(8);
271        let op_did = did_for_op("darby", &op_pk);
272        let org_did = did_for_org("slanchaai", &real_org_pk); // commits to real org key
273        let session_did = "did:wire:x-1";
274        let op_cert = sign_did_cert(&op_sk, session_did).unwrap();
275        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
276        // present the WRONG org pubkey inline → org commitment fails → skipped → none verify
277        let c = card(
278            session_did,
279            Some(&op_did),
280            Some(&op_pk),
281            Some(&op_cert),
282            &[(&org_did, Some(&wrong_org_pk), &member_cert)],
283        );
284        assert!(matches!(
285            evaluate_card_membership(&c),
286            MembershipOutcome::Rejected { .. }
287        ));
288    }
289
290    #[test]
291    fn rejected_when_op_cert_forged() {
292        let (_, op_pk) = keypair(1);
293        let (attacker_sk, _) = keypair(9);
294        let (org_sk, org_pk) = keypair(2);
295        let op_did = did_for_op("darby", &op_pk);
296        let org_did = did_for_org("slanchaai", &org_pk);
297        let session_did = "did:wire:x-1";
298        let forged = sign_did_cert(&attacker_sk, session_did).unwrap();
299        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
300        let c = card(
301            session_did,
302            Some(&op_did),
303            Some(&op_pk),
304            Some(&forged),
305            &[(&org_did, Some(&org_pk), &member_cert)],
306        );
307        assert!(matches!(
308            evaluate_card_membership(&c),
309            MembershipOutcome::Rejected { .. }
310        ));
311    }
312
313    #[test]
314    fn rejected_when_member_cert_forged() {
315        let (op_sk, op_pk) = keypair(1);
316        let (_, org_pk) = keypair(2);
317        let (attacker_sk, _) = keypair(9);
318        let op_did = did_for_op("darby", &op_pk);
319        let org_did = did_for_org("slanchaai", &org_pk);
320        let session_did = "did:wire:x-1";
321        let op_cert = sign_did_cert(&op_sk, session_did).unwrap();
322        let forged_member = sign_did_cert(&attacker_sk, &op_did).unwrap();
323        let c = card(
324            session_did,
325            Some(&op_did),
326            Some(&op_pk),
327            Some(&op_cert),
328            &[(&org_did, Some(&org_pk), &forged_member)],
329        );
330        assert!(matches!(
331            evaluate_card_membership(&c),
332            MembershipOutcome::Rejected { .. }
333        ));
334    }
335
336    #[test]
337    fn rejected_when_org_pubkey_absent() {
338        let (op_sk, op_pk) = keypair(1);
339        let (org_sk, org_pk) = keypair(2);
340        let op_did = did_for_op("darby", &op_pk);
341        let org_did = did_for_org("slanchaai", &org_pk);
342        let session_did = "did:wire:x-1";
343        let op_cert = sign_did_cert(&op_sk, session_did).unwrap();
344        let member_cert = sign_did_cert(&org_sk, &op_did).unwrap();
345        let c = card(
346            session_did,
347            Some(&op_did),
348            Some(&op_pk),
349            Some(&op_cert),
350            &[(&org_did, None, &member_cert)], // no inline org_pubkey
351        );
352        assert!(matches!(
353            evaluate_card_membership(&c),
354            MembershipOutcome::Rejected { .. }
355        ));
356    }
357
358    #[test]
359    fn rejected_when_op_did_without_op_cert() {
360        let (_, op_pk) = keypair(1);
361        let op_did = did_for_op("darby", &op_pk);
362        let c = card("did:wire:x-1", Some(&op_did), Some(&op_pk), None, &[]);
363        assert!(matches!(
364            evaluate_card_membership(&c),
365            MembershipOutcome::Rejected { .. }
366        ));
367    }
368
369    #[test]
370    fn rejected_when_op_did_slot_is_a_session_did() {
371        let c = card(
372            "did:wire:x-1",
373            Some("did:wire:not-an-op-did"),
374            None,
375            Some("AA=="),
376            &[],
377        );
378        assert!(matches!(
379            evaluate_card_membership(&c),
380            MembershipOutcome::Rejected { .. }
381        ));
382    }
383}