1use crate::agent_card::{self, AgentCard};
29use crate::identity::{verify_member_cert, verify_op_cert};
30use crate::signing::b64decode;
31
32#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum MembershipOutcome {
35 NoClaim,
37 Verified {
41 op_did: String,
42 org_dids: Vec<String>,
43 },
44 Rejected { reason: String },
48}
49
50fn 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
61fn commits_to(did: &str, pubkey: &[u8; 32]) -> bool {
65 did.ends_with(&format!("-{}", agent_card::long_fingerprint(pubkey)))
66}
67
68pub 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 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; };
142 if !commits_to(org_did, &org_pubkey) {
143 continue; }
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); 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 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)], );
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}