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 same_machine_attestation: None,
70 })
71}
72
73pub 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
85pub 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 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"); obj.remove("nostr_pubkey"); obj.remove("signature");
112
113 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 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); };
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 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 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); 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(); crate::config::write_op_handle("darby").unwrap();
288 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 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 #[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 #[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 assert!(matches!(
365 evaluate_card_membership(&card),
366 MembershipOutcome::Rejected { .. }
367 ));
368 }
369
370 #[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 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 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 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 let on_disk = crate::config::read_agent_card().unwrap();
411 assert_eq!(on_disk, signed);
412 });
413 }
414
415 #[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 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 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 #[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 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 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 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 #[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 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 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 #[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}