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 let Ok(op_sk) = crate::config::read_op_key() else {
80 return Ok(card); };
82 let session_did = card
83 .get("did")
84 .and_then(|v| v.as_str())
85 .unwrap_or_default()
86 .to_string();
87 if session_did.is_empty() {
88 return Ok(card);
89 }
90 let op_handle = crate::config::read_op_handle()
91 .ok()
92 .flatten()
93 .unwrap_or_else(|| "operator".to_string());
94 let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
95 .verifying_key()
96 .to_bytes();
97
98 let mut memberships = Vec::new();
99 for m in crate::config::read_memberships().unwrap_or_default() {
100 let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
101 m.get("org_did").and_then(|v| v.as_str()),
102 m.get("org_pubkey").and_then(|v| v.as_str()),
103 m.get("member_cert").and_then(|v| v.as_str()),
104 ) else {
105 continue;
106 };
107 let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
108 continue;
109 };
110 if bytes.len() != 32 {
111 continue;
112 }
113 let mut org_pk = [0u8; 32];
114 org_pk.copy_from_slice(&bytes);
115 memberships.push(MemberOf {
116 org_did: org_did.to_string(),
117 org_pubkey: org_pk,
118 member_cert: member_cert.to_string(),
119 });
120 }
121
122 let project = card
123 .get("project")
124 .and_then(|v| v.as_str())
125 .map(str::to_string);
126 let claims = match build_member_claims(
130 &op_handle,
131 &op_sk,
132 &op_pk,
133 &session_did,
134 &memberships,
135 project,
136 ) {
137 Ok(c) => c,
138 Err(e) => {
139 eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
140 return Ok(card);
141 }
142 };
143 match crate::agent_card::with_identity_claims(&card, &claims) {
144 Ok(c) => Ok(c),
145 Err(e) => {
146 eprintln!("wire: op-claims skipped (attach failed: {e:?})");
147 Ok(card)
148 }
149 }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155 use crate::agent_card::{
156 build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
157 };
158 use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
159 use crate::signing::generate_keypair;
160
161 #[test]
162 fn with_op_claims_attaches_when_enrolled() {
163 crate::config::test_support::with_temp_home(|| {
164 let (op_sk, op_pk) = generate_keypair();
165 crate::config::write_op_key(&op_sk).unwrap();
166 crate::config::write_op_handle("darby").unwrap();
167 let op_did = did_for_op("darby", &op_pk);
168
169 let (org_sk, org_pk) = generate_keypair();
170 let org_did = did_for_org("slanchaai", &org_pk);
171 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
172 crate::config::add_membership(
173 &org_did,
174 &crate::signing::b64encode(&org_pk),
175 &member_cert,
176 )
177 .unwrap();
178
179 let (_sess_sk, sess_pk) = generate_keypair();
180 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
181 let with = with_op_claims_if_enrolled(base).unwrap();
182 assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
183 assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
184 });
185 }
186
187 #[test]
188 fn with_op_claims_noop_when_not_enrolled() {
189 crate::config::test_support::with_temp_home(|| {
190 let (_sk, pk) = generate_keypair();
191 let base = build_agent_card("plain", &pk, None, None, None);
192 let out = with_op_claims_if_enrolled(base.clone()).unwrap();
193 assert_eq!(out, base); assert_eq!(crate::agent_card::card_op_did(&out), None);
195 });
196 }
197
198 #[test]
199 fn with_op_claims_failsoft_on_corrupt_memberships() {
200 crate::config::test_support::with_temp_home(|| {
201 let (op_sk, _op_pk) = generate_keypair();
202 crate::config::write_op_key(&op_sk).unwrap(); crate::config::write_op_handle("darby").unwrap();
204 std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
206
207 let (_s, pk) = generate_keypair();
208 let base = build_agent_card("vesper-valley", &pk, None, None, None);
209 let out = with_op_claims_if_enrolled(base).unwrap();
211 assert!(crate::agent_card::card_op_did(&out).is_some());
212 assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
213 });
214 }
215
216 #[test]
218 fn built_claims_verify_offline() {
219 let (op_sk, op_pk) = generate_keypair();
220 let (org_sk, org_pk) = generate_keypair();
221 let (sess_sk, sess_pk) = generate_keypair();
222
223 let op_did = did_for_op("darby", &op_pk);
224 let org_did = did_for_org("slanchaai", &org_pk);
225 let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
226
227 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
228 let session_did = base
229 .get("did")
230 .and_then(|v| v.as_str())
231 .unwrap()
232 .to_string();
233
234 let claims = build_member_claims(
235 "darby",
236 &op_sk,
237 &op_pk,
238 &session_did,
239 &[MemberOf {
240 org_did: org_did.clone(),
241 org_pubkey: org_pk,
242 member_cert,
243 }],
244 Some("print-shop".into()),
245 )
246 .unwrap();
247
248 let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
249 verify_agent_card(&card).unwrap();
250 assert_eq!(
251 evaluate_card_membership(&card),
252 MembershipOutcome::Verified {
253 op_did,
254 org_dids: vec![org_did]
255 }
256 );
257 }
258
259 #[test]
262 fn operator_without_org_builds_but_is_not_verified() {
263 let (op_sk, op_pk) = generate_keypair();
264 let (sess_sk, sess_pk) = generate_keypair();
265 let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
266 let session_did = base
267 .get("did")
268 .and_then(|v| v.as_str())
269 .unwrap()
270 .to_string();
271
272 let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
273 assert!(claims.op_did.is_some());
274 assert!(claims.op_cert.is_some());
275 assert!(claims.op_pubkey.is_some());
276 assert!(claims.org_memberships.is_empty());
277
278 let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
279 assert!(matches!(
281 evaluate_card_membership(&card),
282 MembershipOutcome::Rejected { .. }
283 ));
284 }
285}