1use crate::agent_card::{IdentityClaims, OrgMembership, did_for_op};
17use crate::identity::{CertError, sign_did_cert};
18use crate::signing::b64encode;
19
20pub struct MemberOf {
23 pub org_did: String,
24 pub org_pubkey: [u8; 32],
25 pub member_cert: String,
26}
27
28pub fn issue_member_cert(org_sk: &[u8], op_did: &str) -> Result<String, CertError> {
33 sign_did_cert(org_sk, op_did)
34}
35
36pub 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
70pub 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
82pub 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 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 current_wire_cap = format!("wire/{}", crate::agent_card::CARD_SCHEMA_VERSION);
118 let preserved_caps: Vec<serde_json::Value> = obj
119 .get("capabilities")
120 .and_then(serde_json::Value::as_array)
121 .map(|arr| {
122 arr.iter()
123 .filter(|c| c.as_str().map(|s| !s.starts_with("wire/")).unwrap_or(false))
124 .cloned()
125 .collect()
126 })
127 .unwrap_or_default();
128 let mut new_caps = vec![serde_json::Value::String(current_wire_cap)];
129 new_caps.extend(preserved_caps);
130 obj.insert("capabilities".into(), serde_json::Value::Array(new_caps));
131 }
132 let card = with_op_claims_if_enrolled_inner(card)?;
133 let sk = crate::config::read_private_key()
134 .context("no session signing key on disk — re-run `wire init`")?;
135 let signed = crate::agent_card::sign_agent_card(&card, &sk);
136 crate::config::write_agent_card(&signed)?;
137 Ok(signed)
138}
139
140fn with_op_claims_if_enrolled_inner(
141 card: crate::agent_card::AgentCard,
142) -> anyhow::Result<crate::agent_card::AgentCard> {
143 let Ok(op_sk) = crate::config::read_op_key() else {
144 return Ok(card); };
146 let session_did = card
147 .get("did")
148 .and_then(|v| v.as_str())
149 .unwrap_or_default()
150 .to_string();
151 if session_did.is_empty() {
152 return Ok(card);
153 }
154 let op_handle = crate::config::read_op_handle()
155 .ok()
156 .flatten()
157 .unwrap_or_else(|| "operator".to_string());
158 let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
159 .verifying_key()
160 .to_bytes();
161
162 let mut memberships = Vec::new();
163 for m in crate::config::read_memberships().unwrap_or_default() {
164 let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
165 m.get("org_did").and_then(|v| v.as_str()),
166 m.get("org_pubkey").and_then(|v| v.as_str()),
167 m.get("member_cert").and_then(|v| v.as_str()),
168 ) else {
169 continue;
170 };
171 let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
172 continue;
173 };
174 if bytes.len() != 32 {
175 continue;
176 }
177 let mut org_pk = [0u8; 32];
178 org_pk.copy_from_slice(&bytes);
179 memberships.push(MemberOf {
180 org_did: org_did.to_string(),
181 org_pubkey: org_pk,
182 member_cert: member_cert.to_string(),
183 });
184 }
185
186 let project = card
187 .get("project")
188 .and_then(|v| v.as_str())
189 .map(str::to_string);
190 let claims = match build_member_claims(
194 &op_handle,
195 &op_sk,
196 &op_pk,
197 &session_did,
198 &memberships,
199 project,
200 ) {
201 Ok(c) => c,
202 Err(e) => {
203 eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
204 return Ok(card);
205 }
206 };
207 match crate::agent_card::with_identity_claims(&card, &claims) {
208 Ok(c) => Ok(c),
209 Err(e) => {
210 eprintln!("wire: op-claims skipped (attach failed: {e:?})");
211 Ok(card)
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::agent_card::{
220 build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
221 };
222 use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
223 use crate::signing::generate_keypair;
224
225 #[test]
226 fn with_op_claims_attaches_when_enrolled() {
227 crate::config::test_support::with_temp_home(|| {
228 let (op_sk, op_pk) = generate_keypair();
229 crate::config::write_op_key(&op_sk).unwrap();
230 crate::config::write_op_handle("darby").unwrap();
231 let op_did = did_for_op("darby", &op_pk);
232
233 let (org_sk, org_pk) = generate_keypair();
234 let org_did = did_for_org("slanchaai", &org_pk);
235 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
236 crate::config::add_membership(
237 &org_did,
238 &crate::signing::b64encode(&org_pk),
239 &member_cert,
240 )
241 .unwrap();
242
243 let (_sess_sk, sess_pk) = generate_keypair();
244 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
245 let with = with_op_claims_if_enrolled(base).unwrap();
246 assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
247 assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
248 });
249 }
250
251 #[test]
252 fn with_op_claims_noop_when_not_enrolled() {
253 crate::config::test_support::with_temp_home(|| {
254 let (_sk, pk) = generate_keypair();
255 let base = build_agent_card("plain", &pk, None, None, None);
256 let out = with_op_claims_if_enrolled(base.clone()).unwrap();
257 assert_eq!(out, base); assert_eq!(crate::agent_card::card_op_did(&out), None);
259 });
260 }
261
262 #[test]
263 fn with_op_claims_failsoft_on_corrupt_memberships() {
264 crate::config::test_support::with_temp_home(|| {
265 let (op_sk, _op_pk) = generate_keypair();
266 crate::config::write_op_key(&op_sk).unwrap(); crate::config::write_op_handle("darby").unwrap();
268 std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
270
271 let (_s, pk) = generate_keypair();
272 let base = build_agent_card("vesper-valley", &pk, None, None, None);
273 let out = with_op_claims_if_enrolled(base).unwrap();
275 assert!(crate::agent_card::card_op_did(&out).is_some());
276 assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
277 });
278 }
279
280 #[test]
282 fn built_claims_verify_offline() {
283 let (op_sk, op_pk) = generate_keypair();
284 let (org_sk, org_pk) = generate_keypair();
285 let (sess_sk, sess_pk) = generate_keypair();
286
287 let op_did = did_for_op("darby", &op_pk);
288 let org_did = did_for_org("slanchaai", &org_pk);
289 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
290
291 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
292 let session_did = base
293 .get("did")
294 .and_then(|v| v.as_str())
295 .unwrap()
296 .to_string();
297
298 let claims = build_member_claims(
299 "darby",
300 &op_sk,
301 &op_pk,
302 &session_did,
303 &[MemberOf {
304 org_did: org_did.clone(),
305 org_pubkey: org_pk,
306 member_cert,
307 }],
308 Some("print-shop".into()),
309 )
310 .unwrap();
311
312 let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
313 verify_agent_card(&card).unwrap();
314 assert_eq!(
315 evaluate_card_membership(&card),
316 MembershipOutcome::Verified {
317 op_did,
318 org_dids: vec![org_did]
319 }
320 );
321 }
322
323 #[test]
326 fn operator_without_org_builds_but_is_not_verified() {
327 let (op_sk, op_pk) = generate_keypair();
328 let (sess_sk, sess_pk) = generate_keypair();
329 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
330 let session_did = base
331 .get("did")
332 .and_then(|v| v.as_str())
333 .unwrap()
334 .to_string();
335
336 let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
337 assert!(claims.op_did.is_some());
338 assert!(claims.op_cert.is_some());
339 assert!(claims.op_pubkey.is_some());
340 assert!(claims.org_memberships.is_empty());
341
342 let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
343 assert!(matches!(
345 evaluate_card_membership(&card),
346 MembershipOutcome::Rejected { .. }
347 ));
348 }
349
350 #[test]
353 fn rebuild_picks_up_post_init_enrollment() {
354 crate::config::test_support::with_temp_home(|| {
355 std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
356 let (sess_sk, sess_pk) = generate_keypair();
358 crate::config::write_private_key(&sess_sk).unwrap();
359 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
360 crate::config::write_agent_card(&sign_agent_card(&base, &sess_sk)).unwrap();
361 assert_eq!(
362 crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap()),
363 None
364 );
365
366 let (op_sk, op_pk) = generate_keypair();
368 crate::config::write_op_key(&op_sk).unwrap();
369 crate::config::write_op_handle("darby").unwrap();
370 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
371 let (org_sk, org_pk) = generate_keypair();
372 let org_did = did_for_org("slanchaai", &org_pk);
373 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
374 crate::config::add_membership(
375 &org_did,
376 &crate::signing::b64encode(&org_pk),
377 &member_cert,
378 )
379 .unwrap();
380
381 let signed = rebuild_card_with_current_claims().unwrap();
383 verify_agent_card(&signed).unwrap();
384 assert_eq!(
385 crate::agent_card::card_op_did(&signed),
386 Some(op_did.as_str())
387 );
388 assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 1);
389 let on_disk = crate::config::read_agent_card().unwrap();
391 assert_eq!(on_disk, signed);
392 });
393 }
394
395 #[test]
398 fn rebuild_strips_stale_claims_when_unenrolled() {
399 crate::config::test_support::with_temp_home(|| {
400 std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
401 let (sess_sk, sess_pk) = generate_keypair();
402 crate::config::write_private_key(&sess_sk).unwrap();
403
404 let (op_sk, op_pk) = generate_keypair();
407 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
408 let (org_sk, org_pk) = generate_keypair();
409 let org_did = did_for_org("slanchaai", &org_pk);
410 let stale = with_identity_claims(
411 &build_agent_card("vesper-valley", &sess_pk, None, None, None),
412 &IdentityClaims {
413 op_did: Some(op_did.clone()),
414 op_cert: Some(crate::identity::sign_did_cert(&op_sk, &op_did).unwrap()),
415 op_pubkey: Some(crate::signing::b64encode(&op_pk)),
416 org_memberships: vec![OrgMembership {
417 org_did,
418 org_pubkey: crate::signing::b64encode(&org_pk),
419 member_cert: issue_member_cert(&org_sk, &op_did).unwrap(),
420 }],
421 project: None,
422 },
423 )
424 .unwrap();
425 crate::config::write_agent_card(&sign_agent_card(&stale, &sess_sk)).unwrap();
426 assert!(
427 crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap())
428 .is_some()
429 );
430
431 let signed = rebuild_card_with_current_claims().unwrap();
433 verify_agent_card(&signed).unwrap();
434 assert_eq!(crate::agent_card::card_op_did(&signed), None);
435 assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 0);
436 });
437 }
438
439 #[test]
445 fn rebuild_refreshes_wire_capability_to_current() {
446 crate::config::test_support::with_temp_home(|| {
447 std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
448 let (sess_sk, sess_pk) = generate_keypair();
449 crate::config::write_private_key(&sess_sk).unwrap();
450 let legacy = build_agent_card(
452 "slate-lotus",
453 &sess_pk,
454 None,
455 Some(vec!["wire/v3.1".to_string()]),
456 None,
457 );
458 crate::config::write_agent_card(&sign_agent_card(&legacy, &sess_sk)).unwrap();
459 let before = crate::config::read_agent_card().unwrap();
461 assert_eq!(
462 before["capabilities"],
463 serde_json::json!(["wire/v3.1"]),
464 "precondition: stored card has legacy capability"
465 );
466
467 let signed = rebuild_card_with_current_claims().unwrap();
469 verify_agent_card(&signed).unwrap();
470 assert_eq!(
471 signed["capabilities"],
472 serde_json::json!([format!("wire/{}", crate::agent_card::CARD_SCHEMA_VERSION)]),
473 "republish must refresh wire/* to current CARD_SCHEMA_VERSION"
474 );
475 });
476 }
477
478 #[test]
482 fn rebuild_preserves_non_wire_capabilities_through_refresh() {
483 crate::config::test_support::with_temp_home(|| {
484 std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
485 let (sess_sk, sess_pk) = generate_keypair();
486 crate::config::write_private_key(&sess_sk).unwrap();
487 let mixed = build_agent_card(
489 "slate-lotus",
490 &sess_pk,
491 None,
492 Some(vec![
493 "wire/v3.1".to_string(),
494 "custom-tag".to_string(),
495 "org/v1".to_string(),
496 ]),
497 None,
498 );
499 crate::config::write_agent_card(&sign_agent_card(&mixed, &sess_sk)).unwrap();
500
501 let signed = rebuild_card_with_current_claims().unwrap();
502 verify_agent_card(&signed).unwrap();
503 let caps: Vec<String> = signed["capabilities"]
504 .as_array()
505 .unwrap()
506 .iter()
507 .map(|v| v.as_str().unwrap().to_string())
508 .collect();
509 assert_eq!(
512 caps,
513 vec![
514 format!("wire/{}", crate::agent_card::CARD_SCHEMA_VERSION),
515 "custom-tag".to_string(),
516 "org/v1".to_string(),
517 ],
518 "non-wire/* caps must survive the refresh; only wire/* is replaced"
519 );
520 });
521 }
522
523 #[test]
525 fn rebuild_bails_without_init() {
526 crate::config::test_support::with_temp_home(|| {
527 let err = rebuild_card_with_current_claims().unwrap_err();
528 let msg = format!("{err:?}");
529 assert!(
530 msg.contains("agent card") || msg.contains("init"),
531 "got: {msg}"
532 );
533 });
534 }
535}