use crate::agent_card::{IdentityClaims, OrgMembership, did_for_op};
use crate::identity::{CertError, sign_did_cert};
use crate::signing::b64encode;
pub struct MemberOf {
pub org_did: String,
pub org_pubkey: [u8; 32],
pub member_cert: String,
}
pub fn issue_member_cert(org_sk: &[u8], op_did: &str) -> Result<String, CertError> {
sign_did_cert(org_sk, op_did)
}
pub fn build_member_claims(
op_handle: &str,
op_sk: &[u8; 32],
op_pk: &[u8; 32],
session_did: &str,
memberships: &[MemberOf],
project: Option<String>,
) -> Result<IdentityClaims, CertError> {
let op_did = did_for_op(op_handle, op_pk);
let op_cert = sign_did_cert(op_sk, session_did)?;
let org_memberships = memberships
.iter()
.map(|m| OrgMembership {
org_did: m.org_did.clone(),
org_pubkey: b64encode(&m.org_pubkey),
member_cert: m.member_cert.clone(),
})
.collect();
Ok(IdentityClaims {
op_did: Some(op_did),
op_cert: Some(op_cert),
op_pubkey: Some(b64encode(op_pk)),
org_memberships,
project,
})
}
pub fn with_op_claims_if_enrolled(
card: crate::agent_card::AgentCard,
) -> anyhow::Result<crate::agent_card::AgentCard> {
with_op_claims_if_enrolled_inner(card)
}
pub fn rebuild_card_with_current_claims() -> anyhow::Result<crate::agent_card::AgentCard> {
use anyhow::Context;
let mut card = crate::config::read_agent_card()
.context("no stored agent card — run `wire init` before `wire enroll republish`")?;
if let Some(obj) = card.as_object_mut() {
obj.remove("op_did");
obj.remove("op_cert");
obj.remove("op_pubkey");
obj.remove("org_memberships");
obj.remove("signature");
}
let card = with_op_claims_if_enrolled_inner(card)?;
let sk = crate::config::read_private_key()
.context("no session signing key on disk — re-run `wire init`")?;
let signed = crate::agent_card::sign_agent_card(&card, &sk);
crate::config::write_agent_card(&signed)?;
Ok(signed)
}
fn with_op_claims_if_enrolled_inner(
card: crate::agent_card::AgentCard,
) -> anyhow::Result<crate::agent_card::AgentCard> {
let Ok(op_sk) = crate::config::read_op_key() else {
return Ok(card); };
let session_did = card
.get("did")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
if session_did.is_empty() {
return Ok(card);
}
let op_handle = crate::config::read_op_handle()
.ok()
.flatten()
.unwrap_or_else(|| "operator".to_string());
let op_pk = ed25519_dalek::SigningKey::from_bytes(&op_sk)
.verifying_key()
.to_bytes();
let mut memberships = Vec::new();
for m in crate::config::read_memberships().unwrap_or_default() {
let (Some(org_did), Some(org_pubkey_b64), Some(member_cert)) = (
m.get("org_did").and_then(|v| v.as_str()),
m.get("org_pubkey").and_then(|v| v.as_str()),
m.get("member_cert").and_then(|v| v.as_str()),
) else {
continue;
};
let Ok(bytes) = crate::signing::b64decode(org_pubkey_b64) else {
continue;
};
if bytes.len() != 32 {
continue;
}
let mut org_pk = [0u8; 32];
org_pk.copy_from_slice(&bytes);
memberships.push(MemberOf {
org_did: org_did.to_string(),
org_pubkey: org_pk,
member_cert: member_cert.to_string(),
});
}
let project = card
.get("project")
.and_then(|v| v.as_str())
.map(str::to_string);
let claims = match build_member_claims(
&op_handle,
&op_sk,
&op_pk,
&session_did,
&memberships,
project,
) {
Ok(c) => c,
Err(e) => {
eprintln!("wire: op-claims skipped (cert build failed: {e:?})");
return Ok(card);
}
};
match crate::agent_card::with_identity_claims(&card, &claims) {
Ok(c) => Ok(c),
Err(e) => {
eprintln!("wire: op-claims skipped (attach failed: {e:?})");
Ok(card)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agent_card::{
build_agent_card, did_for_org, sign_agent_card, verify_agent_card, with_identity_claims,
};
use crate::org_membership::{MembershipOutcome, evaluate_card_membership};
use crate::signing::generate_keypair;
#[test]
fn with_op_claims_attaches_when_enrolled() {
crate::config::test_support::with_temp_home(|| {
let (op_sk, op_pk) = generate_keypair();
crate::config::write_op_key(&op_sk).unwrap();
crate::config::write_op_handle("darby").unwrap();
let op_did = did_for_op("darby", &op_pk);
let (org_sk, org_pk) = generate_keypair();
let org_did = did_for_org("slanchaai", &org_pk);
let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
crate::config::add_membership(
&org_did,
&crate::signing::b64encode(&org_pk),
&member_cert,
)
.unwrap();
let (_sess_sk, sess_pk) = generate_keypair();
let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
let with = with_op_claims_if_enrolled(base).unwrap();
assert_eq!(crate::agent_card::card_op_did(&with), Some(op_did.as_str()));
assert_eq!(crate::agent_card::card_org_memberships(&with).len(), 1);
});
}
#[test]
fn with_op_claims_noop_when_not_enrolled() {
crate::config::test_support::with_temp_home(|| {
let (_sk, pk) = generate_keypair();
let base = build_agent_card("plain", &pk, None, None, None);
let out = with_op_claims_if_enrolled(base.clone()).unwrap();
assert_eq!(out, base); assert_eq!(crate::agent_card::card_op_did(&out), None);
});
}
#[test]
fn with_op_claims_failsoft_on_corrupt_memberships() {
crate::config::test_support::with_temp_home(|| {
let (op_sk, _op_pk) = generate_keypair();
crate::config::write_op_key(&op_sk).unwrap(); crate::config::write_op_handle("darby").unwrap();
std::fs::write(crate::config::memberships_path().unwrap(), b"{ not json").unwrap();
let (_s, pk) = generate_keypair();
let base = build_agent_card("vesper-valley", &pk, None, None, None);
let out = with_op_claims_if_enrolled(base).unwrap();
assert!(crate::agent_card::card_op_did(&out).is_some());
assert_eq!(crate::agent_card::card_org_memberships(&out).len(), 0);
});
}
#[test]
fn built_claims_verify_offline() {
let (op_sk, op_pk) = generate_keypair();
let (org_sk, org_pk) = generate_keypair();
let (sess_sk, sess_pk) = generate_keypair();
let op_did = did_for_op("darby", &op_pk);
let org_did = did_for_org("slanchaai", &org_pk);
let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
let session_did = base
.get("did")
.and_then(|v| v.as_str())
.unwrap()
.to_string();
let claims = build_member_claims(
"darby",
&op_sk,
&op_pk,
&session_did,
&[MemberOf {
org_did: org_did.clone(),
org_pubkey: org_pk,
member_cert,
}],
Some("print-shop".into()),
)
.unwrap();
let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
verify_agent_card(&card).unwrap();
assert_eq!(
evaluate_card_membership(&card),
MembershipOutcome::Verified {
op_did,
org_dids: vec![org_did]
}
);
}
#[test]
fn operator_without_org_builds_but_is_not_verified() {
let (op_sk, op_pk) = generate_keypair();
let (sess_sk, sess_pk) = generate_keypair();
let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
let session_did = base
.get("did")
.and_then(|v| v.as_str())
.unwrap()
.to_string();
let claims = build_member_claims("darby", &op_sk, &op_pk, &session_did, &[], None).unwrap();
assert!(claims.op_did.is_some());
assert!(claims.op_cert.is_some());
assert!(claims.op_pubkey.is_some());
assert!(claims.org_memberships.is_empty());
let card = sign_agent_card(&with_identity_claims(&base, &claims).unwrap(), &sess_sk);
assert!(matches!(
evaluate_card_membership(&card),
MembershipOutcome::Rejected { .. }
));
}
#[test]
fn rebuild_picks_up_post_init_enrollment() {
crate::config::test_support::with_temp_home(|| {
std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
let (sess_sk, sess_pk) = generate_keypair();
crate::config::write_private_key(&sess_sk).unwrap();
let base = build_agent_card("vesper-valley", &sess_pk, None, None, None);
crate::config::write_agent_card(&sign_agent_card(&base, &sess_sk)).unwrap();
assert_eq!(
crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap()),
None
);
let (op_sk, op_pk) = generate_keypair();
crate::config::write_op_key(&op_sk).unwrap();
crate::config::write_op_handle("darby").unwrap();
let op_did = crate::agent_card::did_for_op("darby", &op_pk);
let (org_sk, org_pk) = generate_keypair();
let org_did = did_for_org("slanchaai", &org_pk);
let member_cert = issue_member_cert(&org_sk, &op_did).unwrap();
crate::config::add_membership(
&org_did,
&crate::signing::b64encode(&org_pk),
&member_cert,
)
.unwrap();
let signed = rebuild_card_with_current_claims().unwrap();
verify_agent_card(&signed).unwrap();
assert_eq!(
crate::agent_card::card_op_did(&signed),
Some(op_did.as_str())
);
assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 1);
let on_disk = crate::config::read_agent_card().unwrap();
assert_eq!(on_disk, signed);
});
}
#[test]
fn rebuild_strips_stale_claims_when_unenrolled() {
crate::config::test_support::with_temp_home(|| {
std::fs::create_dir_all(crate::config::config_dir().unwrap()).unwrap();
let (sess_sk, sess_pk) = generate_keypair();
crate::config::write_private_key(&sess_sk).unwrap();
let (op_sk, op_pk) = generate_keypair();
let op_did = crate::agent_card::did_for_op("darby", &op_pk);
let (org_sk, org_pk) = generate_keypair();
let org_did = did_for_org("slanchaai", &org_pk);
let stale = with_identity_claims(
&build_agent_card("vesper-valley", &sess_pk, None, None, None),
&IdentityClaims {
op_did: Some(op_did.clone()),
op_cert: Some(crate::identity::sign_did_cert(&op_sk, &op_did).unwrap()),
op_pubkey: Some(crate::signing::b64encode(&op_pk)),
org_memberships: vec![OrgMembership {
org_did,
org_pubkey: crate::signing::b64encode(&org_pk),
member_cert: issue_member_cert(&org_sk, &op_did).unwrap(),
}],
project: None,
},
)
.unwrap();
crate::config::write_agent_card(&sign_agent_card(&stale, &sess_sk)).unwrap();
assert!(
crate::agent_card::card_op_did(&crate::config::read_agent_card().unwrap())
.is_some()
);
let signed = rebuild_card_with_current_claims().unwrap();
verify_agent_card(&signed).unwrap();
assert_eq!(crate::agent_card::card_op_did(&signed), None);
assert_eq!(crate::agent_card::card_org_memberships(&signed).len(), 0);
});
}
#[test]
fn rebuild_bails_without_init() {
crate::config::test_support::with_temp_home(|| {
let err = rebuild_card_with_current_claims().unwrap_err();
let msg = format!("{err:?}");
assert!(
msg.contains("agent card") || msg.contains("init"),
"got: {msg}"
);
});
}
}