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 card = with_op_claims_if_enrolled_inner(card)?;
109 let sk = crate::config::read_private_key()
110 .context("no session signing key on disk — re-run `wire init`")?;
111 let signed = crate::agent_card::sign_agent_card(&card, &sk);
112 crate::config::write_agent_card(&signed)?;
113 Ok(signed)
114}
115
116fn with_op_claims_if_enrolled_inner(
117 card: crate::agent_card::AgentCard,
118) -> anyhow::Result<crate::agent_card::AgentCard> {
119 let Ok(op_sk) = crate::config::read_op_key() else {
120 return Ok(card); };
122 let session_did = card
123 .get("did")
124 .and_then(|v| v.as_str())
125 .unwrap_or_default()
126 .to_string();
127 if session_did.is_empty() {
128 return Ok(card);
129 }
130 let op_handle = crate::config::read_op_handle()
131 .ok()
132 .flatten()
133 .unwrap_or_else(|| "operator".to_string());
134 let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
135 .verifying_key()
136 .to_bytes();
137
138 let mut memberships = Vec::new();
139 for m in crate::config::read_memberships().unwrap_or_default() {
140 let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
141 m.get("org_did").and_then(|v| v.as_str()),
142 m.get("org_pubkey").and_then(|v| v.as_str()),
143 m.get("member_cert").and_then(|v| v.as_str()),
144 ) else {
145 continue;
146 };
147 let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
148 continue;
149 };
150 if bytes.len() != 32 {
151 continue;
152 }
153 let mut org_pk = [0u8; 32];
154 org_pk.copy_from_slice(&bytes);
155 memberships.push(MemberOf {
156 org_did: org_did.to_string(),
157 org_pubkey: org_pk,
158 member_cert: member_cert.to_string(),
159 });
160 }
161
162 let project = card
163 .get("project")
164 .and_then(|v| v.as_str())
165 .map(str::to_string);
166 let claims = match build_member_claims(
170 &op_handle,
171 &op_sk,
172 &op_pk,
173 &session_did,
174 &memberships,
175 project,
176 ) {
177 Ok(c) => c,
178 Err(e) => {
179 eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
180 return Ok(card);
181 }
182 };
183 match crate::agent_card::with_identity_claims(&card, &claims) {
184 Ok(c) => Ok(c),
185 Err(e) => {
186 eprintln!("wire: op-claims skipped (attach failed: {e:?})");
187 Ok(card)
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::agent_card::{
196 build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
197 };
198 use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
199 use crate::signing::generate_keypair;
200
201 #[test]
202 fn with_op_claims_attaches_when_enrolled() {
203 crate::config::test_support::with_temp_home(|| {
204 let (op_sk, op_pk) = generate_keypair();
205 crate::config::write_op_key(&op_sk).unwrap();
206 crate::config::write_op_handle("darby").unwrap();
207 let op_did = did_for_op("darby", &op_pk);
208
209 let (org_sk, org_pk) = generate_keypair();
210 let org_did = did_for_org("slanchaai", &org_pk);
211 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
212 crate::config::add_membership(
213 &org_did,
214 &crate::signing::b64encode(&org_pk),
215 &member_cert,
216 )
217 .unwrap();
218
219 let (_sess_sk, sess_pk) = generate_keypair();
220 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
221 let with = with_op_claims_if_enrolled(base).unwrap();
222 assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
223 assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
224 });
225 }
226
227 #[test]
228 fn with_op_claims_noop_when_not_enrolled() {
229 crate::config::test_support::with_temp_home(|| {
230 let (_sk, pk) = generate_keypair();
231 let base = build_agent_card("plain", &pk, None, None, None);
232 let out = with_op_claims_if_enrolled(base.clone()).unwrap();
233 assert_eq!(out, base); assert_eq!(crate::agent_card::card_op_did(&out), None);
235 });
236 }
237
238 #[test]
239 fn with_op_claims_failsoft_on_corrupt_memberships() {
240 crate::config::test_support::with_temp_home(|| {
241 let (op_sk, _op_pk) = generate_keypair();
242 crate::config::write_op_key(&op_sk).unwrap(); crate::config::write_op_handle("darby").unwrap();
244 std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
246
247 let (_s, pk) = generate_keypair();
248 let base = build_agent_card("vesper-valley", &pk, None, None, None);
249 let out = with_op_claims_if_enrolled(base).unwrap();
251 assert!(crate::agent_card::card_op_did(&out).is_some());
252 assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
253 });
254 }
255
256 #[test]
258 fn built_claims_verify_offline() {
259 let (op_sk, op_pk) = generate_keypair();
260 let (org_sk, org_pk) = generate_keypair();
261 let (sess_sk, sess_pk) = generate_keypair();
262
263 let op_did = did_for_op("darby", &op_pk);
264 let org_did = did_for_org("slanchaai", &org_pk);
265 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
266
267 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
268 let session_did = base
269 .get("did")
270 .and_then(|v| v.as_str())
271 .unwrap()
272 .to_string();
273
274 let claims = build_member_claims(
275 "darby",
276 &op_sk,
277 &op_pk,
278 &session_did,
279 &[MemberOf {
280 org_did: org_did.clone(),
281 org_pubkey: org_pk,
282 member_cert,
283 }],
284 Some("print-shop".into()),
285 )
286 .unwrap();
287
288 let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
289 verify_agent_card(&card).unwrap();
290 assert_eq!(
291 evaluate_card_membership(&card),
292 MembershipOutcome::Verified {
293 op_did,
294 org_dids: vec![org_did]
295 }
296 );
297 }
298
299 #[test]
302 fn operator_without_org_builds_but_is_not_verified() {
303 let (op_sk, op_pk) = generate_keypair();
304 let (sess_sk, sess_pk) = generate_keypair();
305 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
306 let session_did = base
307 .get("did")
308 .and_then(|v| v.as_str())
309 .unwrap()
310 .to_string();
311
312 let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
313 assert!(claims.op_did.is_some());
314 assert!(claims.op_cert.is_some());
315 assert!(claims.op_pubkey.is_some());
316 assert!(claims.org_memberships.is_empty());
317
318 let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
319 assert!(matches!(
321 evaluate_card_membership(&card),
322 MembershipOutcome::Rejected { .. }
323 ));
324 }
325
326 #[test]
329 fn rebuild_picks_up_post_init_enrollment() {
330 crate::config::test_support::with_temp_home(|| {
331 std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
332 let (sess_sk, sess_pk) = generate_keypair();
334 crate::config::write_private_key(&sess_sk).unwrap();
335 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
336 crate::config::write_agent_card(&sign_agent_card(&base, &sess_sk)).unwrap();
337 assert_eq!(
338 crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap()),
339 None
340 );
341
342 let (op_sk, op_pk) = generate_keypair();
344 crate::config::write_op_key(&op_sk).unwrap();
345 crate::config::write_op_handle("darby").unwrap();
346 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
347 let (org_sk, org_pk) = generate_keypair();
348 let org_did = did_for_org("slanchaai", &org_pk);
349 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
350 crate::config::add_membership(
351 &org_did,
352 &crate::signing::b64encode(&org_pk),
353 &member_cert,
354 )
355 .unwrap();
356
357 let signed = rebuild_card_with_current_claims().unwrap();
359 verify_agent_card(&signed).unwrap();
360 assert_eq!(
361 crate::agent_card::card_op_did(&signed),
362 Some(op_did.as_str())
363 );
364 assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 1);
365 let on_disk = crate::config::read_agent_card().unwrap();
367 assert_eq!(on_disk, signed);
368 });
369 }
370
371 #[test]
374 fn rebuild_strips_stale_claims_when_unenrolled() {
375 crate::config::test_support::with_temp_home(|| {
376 std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
377 let (sess_sk, sess_pk) = generate_keypair();
378 crate::config::write_private_key(&sess_sk).unwrap();
379
380 let (op_sk, op_pk) = generate_keypair();
383 let op_did = crate::agent_card::did_for_op("darby", &op_pk);
384 let (org_sk, org_pk) = generate_keypair();
385 let org_did = did_for_org("slanchaai", &org_pk);
386 let stale = with_identity_claims(
387 &build_agent_card("vesper-valley", &sess_pk, None, None, None),
388 &IdentityClaims {
389 op_did: Some(op_did.clone()),
390 op_cert: Some(crate::identity::sign_did_cert(&op_sk, &op_did).unwrap()),
391 op_pubkey: Some(crate::signing::b64encode(&op_pk)),
392 org_memberships: vec![OrgMembership {
393 org_did,
394 org_pubkey: crate::signing::b64encode(&org_pk),
395 member_cert: issue_member_cert(&org_sk, &op_did).unwrap(),
396 }],
397 project: None,
398 },
399 )
400 .unwrap();
401 crate::config::write_agent_card(&sign_agent_card(&stale, &sess_sk)).unwrap();
402 assert!(
403 crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap())
404 .is_some()
405 );
406
407 let signed = rebuild_card_with_current_claims().unwrap();
409 verify_agent_card(&signed).unwrap();
410 assert_eq!(crate::agent_card::card_op_did(&signed), None);
411 assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 0);
412 });
413 }
414
415 #[test]
417 fn rebuild_bails_without_init() {
418 crate::config::test_support::with_temp_home(|| {
419 let err = rebuild_card_with_current_claims().unwrap_err();
420 let msg = format!("{err:?}");
421 assert!(
422 msg.contains("agent card") || msg.contains("init"),
423 "got: {msg}"
424 );
425 });
426 }
427}