diff --git a/openvtc-core/src/lib.rs b/openvtc-core/src/lib.rs index 7776915e..bebcac4f 100644 --- a/openvtc-core/src/lib.rs +++ b/openvtc-core/src/lib.rs @@ -38,6 +38,7 @@ pub mod members; pub mod messaging; #[cfg(feature = "openpgp-card")] pub mod openpgp_card; +pub mod personhood; pub mod presentation; pub mod process_lock; pub mod rebuild; diff --git a/openvtc-core/src/personhood.rs b/openvtc-core/src/personhood.rs new file mode 100644 index 00000000..1fa352f9 --- /dev/null +++ b/openvtc-core/src/personhood.rs @@ -0,0 +1,695 @@ +/*! + * Personhood assertion — the member side of the VTC's personhood ceremony. + * + * A community that vets its members can mark them as people. The claim lands + * as a `PersonhoodCredential` type on the member's VMC, which is what DTG + * Credentials means by a PHC ("a PHC is simply a VMC issued by a VTC whose + * governance enforces real human personhood and exactly one membership per + * person"). Nothing here decides whether the member *is* a person — the + * community's `personhood.rego` does, over the evidence this module presents. + * + * ## The ceremony + * + * 1. `vtc/members/personhood/challenge/0.1` → the community mints a + * single-use nonce with a ten-minute life. + * 2. Out of band, two humans confirm they are talking about the same + * ceremony — see [`match_code`]. + * 3. `vtc/members/personhood/assert/0.1` → the member presents a signed VP + * carrying that nonce and whatever credentials the community's policy + * wants to see. + * + * Both verbs ride the same Trust Task document path as the join ceremony + * ([`crate::join`]): DIDComm wraps the document in an authcrypt envelope, TSP + * carries it bare. The sender is cryptographically proven either way, so no + * separate holder-binding signature rides along. + * + * ## Why the challenge is written twice + * + * The published task says the presentation's `proof.challenge` must be the + * paired `challengeId`, and that this is what "stops one captured and + * replayed into another". In W3C Data Integrity that holds because the proof + * options are canonicalised with the document, so `challenge` is signed. + * + * `affinidi_data_integrity` has no `challenge` proof option, and the VTC + * verifies over the presentation with the whole `proof` block removed — so a + * value written only to `proof.challenge` is **not covered by the + * signature**, and swapping it on a captured presentation would go unnoticed. + * + * So [`build_presentation`] writes the challenge to `proof.challenge` (what + * the spec names) *and* to top-level `nonce` (what the signature actually + * covers), and the VTC requires both to agree. Do not "simplify" this by + * dropping one: `proof.challenge` alone is unsigned, and `nonce` alone is + * off-spec. + */ + +use std::sync::Arc; + +use affinidi_data_integrity::crypto_suites::CryptoSuite; +use affinidi_data_integrity::{DataIntegrityProof, SignOptions}; +use affinidi_tdk::{ + didcomm::Message, + messaging::{ATM, profiles::ATMProfile}, + secrets_resolver::secrets::Secret, +}; +use chrono::Utc; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use trust_tasks_rs::TrustTask; +use uuid::Uuid; + +use crate::errors::OpenVTCError; + +/// `vtc/members/personhood/challenge/0.1`. +pub const PERSONHOOD_CHALLENGE_TYPE: &str = + "https://trusttasks.org/spec/vtc/members/personhood/challenge/0.1"; + +/// `vtc/members/personhood/assert/0.1`. +pub const PERSONHOOD_ASSERT_TYPE: &str = + "https://trusttasks.org/spec/vtc/members/personhood/assert/0.1"; + +/// The community's reply to a challenge request. +/// +/// Spelled out rather than built with `format!` because the inbound router +/// matches on it: a `const` can be compared directly, and the pair below is +/// pinned against the request types by a test so a typo cannot make a reply +/// simply never arrive. +pub const PERSONHOOD_CHALLENGE_RESPONSE_TYPE: &str = + "https://trusttasks.org/spec/vtc/members/personhood/challenge/0.1#response"; + +/// The community's reply to an assertion. +pub const PERSONHOOD_ASSERT_RESPONSE_TYPE: &str = + "https://trusttasks.org/spec/vtc/members/personhood/assert/0.1#response"; + +/// W3C VC Data Model 2.0 context — the presentation's `@context`. +const VC_V2_CONTEXT_URL: &str = "https://www.w3.org/ns/credentials/v2"; + +// ─── The spoken match code ─────────────────────────────────────────────── + +/// Domain separation for the match-code derivation. Must match the VTC's +/// `vtc_service::members::match_code::DOMAIN_TAG` byte for byte — the two +/// sides only agree because they compute the same digest over the same +/// input. +const MATCH_DOMAIN_TAG: &[u8] = b"vtc-personhood-match/v1\0"; + +/// Crockford base32 — no `I`, `L`, `O`, `U`, so nothing in a code is +/// confusable when it is said out loud. +const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// Characters in the code, excluding the separator. +const CODE_CHARS: usize = 8; + +/// Bits drawn from the digest — the exact capacity of eight base32 +/// characters. +const CODE_BITS: usize = CODE_CHARS * 5; + +/// Derive the eight-character code for a challenge id, formatted +/// `XXXX-XXXX`. +/// +/// The `challengeId` is a UUID: fine on a wire, hopeless read aloud. This is +/// what the two people in the room actually say to each other, and it is +/// **derived** from the challenge rather than transferred — anyone already +/// holding the id computes the same characters, and anyone who does not +/// cannot. Nothing on the VTC checks it; it proves nothing that +/// `proof.challenge` does not already prove. It exists so a human can tell +/// that the ceremony their client is about to answer is the one the +/// administrator in front of them just started, the way a Bluetooth pairing +/// code does. +/// +/// The VTC returns the same value in the challenge reply's `ext` under +/// `org.openvtc.match-code`; [`match_code`] recomputing it locally means the +/// member's client can show it even when the reply's `ext` is absent, and +/// means a disagreement between the two is visible rather than silent. +pub fn match_code(challenge_id: &Uuid) -> String { + let mut hasher = Sha256::new(); + hasher.update(MATCH_DOMAIN_TAG); + hasher.update(challenge_id.as_bytes()); + let digest = hasher.finalize(); + + let mut out = String::with_capacity(CODE_CHARS + 1); + for (i, bit_offset) in (0..CODE_BITS).step_by(5).enumerate() { + let mut idx = 0u8; + for bit in 0..5 { + let abs = bit_offset + bit; + let byte = digest[abs / 8]; + idx = (idx << 1) | ((byte >> (7 - (abs % 8))) & 1); + } + if i == 4 { + out.push('-'); + } + out.push(CROCKFORD[idx as usize] as char); + } + out +} + +/// Read the match code the VTC sent in a challenge reply's `ext`, if it is +/// there. +/// +/// Prefer comparing this against [`match_code`] rather than trusting either +/// alone: they are computed by different implementations from the same +/// challenge id, so a mismatch means the two sides disagree about the +/// derivation and the spoken confirmation is worthless. Absent is fine — an +/// older VTC simply does not send it. +pub fn match_code_from_reply(ext: &Value) -> Option { + ext.get("org.openvtc.match-code") + .and_then(|v| v.as_str()) + .map(str::to_owned) +} + +// ─── Trust Task documents ──────────────────────────────────────────────── + +/// Wrap a payload in the Trust Task document envelope the VTC's dispatcher +/// reads. Mirrors [`crate::join`]'s builder — the VTC rejects a bare payload +/// as `malformedRequest` ("missing field `id`"), so the shape is not +/// optional for any verb. +fn build_document( + type_uri: &str, + issuer_did: &str, + recipient_did: &str, + document_id: &str, + payload: T, +) -> Result { + let type_uri = type_uri + .parse() + .map_err(|e| OpenVTCError::Config(format!("trust task type URI parse: {e}")))?; + let mut doc = TrustTask::new(document_id.to_string(), type_uri, payload); + doc.issuer = Some(issuer_did.to_string()); + doc.recipient = Some(recipient_did.to_string()); + doc.issued_at = Some(Utc::now()); + serde_json::to_value(&doc) + .map_err(|e| OpenVTCError::Config(format!("trust task document serialize: {e}"))) +} + +/// Everything needed to get a document from this member to that community. +/// +/// Grouped rather than passed as loose arguments because both verbs need the +/// identical set, and a six-parameter tail of same-typed `&str` DIDs is one +/// transposed pair away from sending a member's presentation to their own +/// mediator's DID. +pub struct Route<'a> { + pub atm: &'a ATM, + pub profile: &'a Arc, + /// The member acting — the authcrypt sender / TSP sender VID, and so the + /// identity the community proves this request came from. + pub member_did: &'a str, + /// The community being addressed. + pub vtc_did: &'a str, + /// The member's own mediator, for the DIDComm leg. + pub mediator_did: &'a str, + /// The community's advertised TSP mediator, when it advertises `#tsp`. + /// + /// `Some` sends the bare Trust Task document over TSP; `None` wraps it + /// in DIDComm. Discovery belongs to the caller — same rule as + /// [`crate::join::submit_join_request`] — so a VTC that does not + /// advertise `#tsp` degrades to DIDComm rather than failing. + pub tsp_mediator_did: Option<&'a str>, +} + +impl Route<'_> { + /// Send a built document over whichever transport this route selects. + async fn send( + &self, + body: Value, + type_uri: &str, + document_id: String, + ) -> Result<(), OpenVTCError> { + match self.tsp_mediator_did { + Some(tsp_mediator) => { + crate::tsp::send_trust_task( + self.atm, + self.profile, + &body, + self.vtc_did, + tsp_mediator, + ) + .await?; + } + None => { + let now = Utc::now().timestamp().max(0) as u64; + let msg = Message::build(document_id, type_uri.to_string(), body) + .from(self.member_did.to_string()) + .to(self.vtc_did.to_string()) + .created_time(now) + .finalize(); + crate::pack_and_send( + self.atm, + self.profile, + &msg, + self.member_did, + self.vtc_did, + self.mediator_did, + ) + .await?; + } + } + Ok(()) + } +} + +/// Ask the community for a personhood challenge +/// (`vtc/members/personhood/challenge/0.1`). +/// +/// Returns the correlation handle the VTC's reply threads on — the same +/// value on either transport, for the reason [`crate::join`] documents: the +/// DIDComm message id is set to the Trust Task document id, so DIDComm's +/// `thid` and TSP's `threadId` coincide. +/// +/// The reply carries the `challengeId` to sign against and, from a VTC new +/// enough to send it, the match code in `ext`. Nothing is awaited here; the +/// reply arrives asynchronously like every other Trust Task response. +/// +/// `subject_did` is who the challenge is for. A member asking for their own +/// is the ordinary case; an administrator may ask for another member's, +/// which is the in-person ceremony — the community mints it, the +/// administrator reads the code to the person in front of them, and that +/// person's own client answers it. Minting for someone else confers +/// nothing: the nonce is bound to the subject, and only a presentation +/// signed by the subject's key can spend it. +pub async fn request_challenge(route: &Route<'_>, subject_did: &str) -> Result { + let request_id = Uuid::new_v4(); + let document_id = format!("urn:uuid:{request_id}"); + let body = build_document( + PERSONHOOD_CHALLENGE_TYPE, + route.member_did, + route.vtc_did, + &document_id, + json!({ "did": subject_did }), + )?; + + route + .send(body, PERSONHOOD_CHALLENGE_TYPE, document_id) + .await?; + + Ok(request_id) +} + +/// Build and sign the presentation the assert verb carries. +/// +/// `credentials` are the whole signed VCs to present — `eddsa-jcs-2022` +/// credentials cannot be redacted, so each one is presented entire. Which of +/// them satisfy the community is the community's `personhood.rego` to +/// decide; presenting more than it needs discloses more than it needs, so +/// callers should pass what the community asked for rather than the wallet. +/// +/// The challenge is written to both `nonce` and `proof.challenge` — see this +/// module's header for why neither alone is sufficient. +pub async fn build_presentation( + signing_secret: &Secret, + member_did: &str, + challenge_id: &Uuid, + credentials: Vec, +) -> Result { + let challenge = challenge_id.to_string(); + + // Sign over this exact shape minus `proof` — JCS canonicalisation is + // sensitive to field presence, so the proof is inserted afterwards and + // anything that must be signed has to be in here. + let vp = json!({ + "@context": [VC_V2_CONTEXT_URL], + "type": ["VerifiablePresentation"], + "holder": member_did, + "verifiableCredential": credentials, + "nonce": challenge, + }); + + let proof = DataIntegrityProof::sign( + &vp, + signing_secret, + SignOptions::new() + .with_proof_purpose("authentication") + .with_cryptosuite(CryptoSuite::EddsaJcs2022), + ) + .await + .map_err(|e| OpenVTCError::Config(format!("sign personhood presentation: {e}")))?; + + let mut proof_value = serde_json::to_value(&proof) + .map_err(|e| OpenVTCError::Config(format!("serialize presentation proof: {e}")))?; + // `challenge` is not a field `DataIntegrityProof` carries, so it is + // added to the serialised proof rather than set through `SignOptions`. + // That is precisely why it is unsigned, and why `nonce` above exists. + proof_value + .as_object_mut() + .ok_or_else(|| OpenVTCError::Config("proof did not serialize to an object".into()))? + .insert("challenge".to_string(), Value::String(challenge)); + + let mut signed = vp; + signed + .as_object_mut() + .expect("presentation is an object") + .insert("proof".to_string(), proof_value); + Ok(signed) +} + +/// Assert personhood (`vtc/members/personhood/assert/0.1`). +/// +/// The community verifies the presentation against `member_did`'s resolved +/// key, consumes the challenge, runs its personhood policy, and — if it +/// allows — re-mints the member's VMC carrying `PersonhoodCredential` and +/// their role credential. The reply carries both. +/// +/// The member is always the subject. The community refuses an assertion sent +/// by anyone else, because `assert/0.1` declares `actsAsSubject: true`: this +/// is the member exercising authority over their own personhood state. +pub async fn assert_personhood( + route: &Route<'_>, + signing_secret: &Secret, + challenge_id: &Uuid, + credentials: Vec, +) -> Result { + let presentation = + build_presentation(signing_secret, route.member_did, challenge_id, credentials).await?; + + let request_id = Uuid::new_v4(); + let document_id = format!("urn:uuid:{request_id}"); + let body = build_document( + PERSONHOOD_ASSERT_TYPE, + route.member_did, + route.vtc_did, + &document_id, + json!({ "did": route.member_did, "presentation": presentation }), + )?; + + route + .send(body, PERSONHOOD_ASSERT_TYPE, document_id) + .await?; + + Ok(request_id) +} + +// ─── Replies ───────────────────────────────────────────────────────────── + +/// Take the task-specific payload out of a VTC Trust Task reply body. +/// +/// Same normalisation [`crate::messaging`] applies: anything dispatched +/// through the VTC's `dispatch_trust_task_core` comes back as the whole +/// `#response` document with the members nested under `payload`, while a +/// hand-built reply carries the bare body. None of the bare bodies has a +/// `payload` member, so the test is unambiguous — and it makes this +/// transport-agnostic, since a TSP frame carries the response document raw. +fn reply_payload(body: &Value) -> Value { + match body.get("payload") { + Some(payload) => payload.clone(), + None => body.clone(), + } +} + +/// What the community answered a challenge request with. +#[derive(Debug, Clone)] +pub struct ChallengeReply { + /// The nonce to sign against. + pub challenge_id: Uuid, + /// When the community stops accepting a presentation for it. + pub expires_at: chrono::DateTime, + /// The code to say out loud, derived locally from [`Self::challenge_id`]. + pub match_code: String, +} + +/// Parse a `members/personhood/challenge/0.1#response`. +/// +/// When the community sends its own copy of the match code, it is compared +/// against the one derived here and a disagreement is an error rather than a +/// shrug. The two are computed by different implementations, so if they +/// differ, the spoken confirmation is worthless — and the failure is +/// otherwise invisible, because both sides still show eight plausible +/// characters and the people in the room just conclude they have the wrong +/// ceremony. +pub fn parse_challenge_reply(body: &Value) -> Result { + let payload = reply_payload(body); + + let challenge_id = payload + .get("challengeId") + .and_then(|v| v.as_str()) + .ok_or_else(|| OpenVTCError::Config("challenge reply has no challengeId".into()))? + .parse::() + .map_err(|e| OpenVTCError::Config(format!("challengeId is not a UUID: {e}")))?; + + let expires_at = payload + .get("expiresAt") + .and_then(|v| v.as_str()) + .ok_or_else(|| OpenVTCError::Config("challenge reply has no expiresAt".into()))? + .parse::>() + .map_err(|e| OpenVTCError::Config(format!("expiresAt is not a timestamp: {e}")))?; + + let derived = match_code(&challenge_id); + if let Some(theirs) = payload.get("ext").and_then(match_code_from_reply) + && theirs != derived + { + return Err(OpenVTCError::Config(format!( + "match code disagreement: the community says {theirs}, this client derives \ + {derived} from the same challenge — the two implementations have drifted and \ + the spoken confirmation cannot be trusted" + ))); + } + + Ok(ChallengeReply { + challenge_id, + expires_at, + match_code: derived, + }) +} + +/// What the community answered an assertion with: the flag, and the freshly +/// re-issued credentials that now carry it. +#[derive(Debug, Clone)] +pub struct AssertReply { + pub did: String, + /// Always `true` on success — the community answers a refusal with a + /// `trust-task-error` document, not with `personhood: false`. + pub personhood: bool, + /// The re-minted VMC, carrying `PersonhoodCredential` in its `type`. + pub vmc: Value, + /// The re-minted role credential. + pub role_vec: Value, +} + +/// Parse a `members/personhood/assert/0.1#response`. +pub fn parse_assert_reply(body: &Value) -> Result { + let payload = reply_payload(body); + Ok(AssertReply { + did: payload + .get("did") + .and_then(|v| v.as_str()) + .ok_or_else(|| OpenVTCError::Config("assert reply has no did".into()))? + .to_string(), + personhood: payload + .get("personhood") + .and_then(|v| v.as_bool()) + .unwrap_or(false), + vmc: payload.get("vmc").cloned().unwrap_or(Value::Null), + role_vec: payload.get("roleVec").cloned().unwrap_or(Value::Null), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const MEMBER: &str = "did:key:z6MkjchhfUsD6mmvni8mCdXHw216Xrm9bQe2mBH1P5RDjVJG"; + + fn secret() -> Secret { + Secret::from_multibase( + // Deterministic test key; the DID above is its `did:key` form. + "z3u2en7t5LR2WtQH5PfFqMqwVHBeXouLzo6haApm8XHqvjxq", + Some(&format!("{MEMBER}#key-0")), + ) + .expect("test secret") + } + + /// Each response type is its request type plus `#response`. The inbound + /// router matches these as constants, so a typo would not fail anything — + /// the reply would simply never be routed, and the ceremony would hang + /// with no error anywhere. + #[test] + fn response_types_are_their_request_types() { + assert_eq!( + PERSONHOOD_CHALLENGE_RESPONSE_TYPE, + format!("{PERSONHOOD_CHALLENGE_TYPE}#response") + ); + assert_eq!( + PERSONHOOD_ASSERT_RESPONSE_TYPE, + format!("{PERSONHOOD_ASSERT_TYPE}#response") + ); + } + + /// The code is a pure function of the challenge id — that is the whole + /// mechanism. If it ever stops being one, the administrator and the + /// member see different characters and the spoken confirmation quietly + /// stops meaning anything. + #[test] + fn match_code_is_deterministic() { + let id = Uuid::parse_str("6f1c4f9e-7c2a-4f4b-9a3e-2b1d0c5e8a77").expect("uuid"); + assert_eq!(match_code(&id), match_code(&id)); + } + + /// Shape a human reads aloud, from an alphabet with nothing mishearable + /// in it. + #[test] + fn match_code_is_four_dash_four_from_crockford() { + let code = match_code(&Uuid::new_v4()); + assert_eq!(code.len(), CODE_CHARS + 1); + assert_eq!(code.as_bytes()[4], b'-'); + for c in code.bytes().filter(|c| *c != b'-') { + assert!(CROCKFORD.contains(&c), "{} is outside Crockford", c as char); + } + for c in *b"ILOU" { + assert!(!CROCKFORD.contains(&c), "{} is confusable", c as char); + } + } + + /// **The cross-implementation pin.** This value was produced by the + /// VTC's `vtc_service::members::match_code::derive`. The two sides agree + /// only because they compute the same digest over the same bytes, and + /// nothing else in either repo would catch them drifting — a changed + /// domain tag or alphabet on either side yields eight plausible + /// characters that simply never match, which reads to an operator as + /// "the code is wrong" rather than "the code is broken". + #[test] + fn match_code_agrees_with_the_vtc() { + let id = Uuid::parse_str("6f1c4f9e-7c2a-4f4b-9a3e-2b1d0c5e8a77").expect("uuid"); + assert_eq!(match_code(&id), "5CY1-GZEE"); + } + + /// The challenge must land in both places: `proof.challenge` because + /// that is what the published task names, and `nonce` because that is + /// what the signature covers. A presentation carrying only the first is + /// replayable; one carrying only the second is off-spec. + #[tokio::test] + async fn presentation_carries_the_challenge_signed_and_unsigned() { + let id = Uuid::new_v4(); + let vp = build_presentation(&secret(), MEMBER, &id, vec![]) + .await + .expect("build presentation"); + + assert_eq!( + vp["nonce"].as_str(), + Some(id.to_string().as_str()), + "the signed copy of the challenge is missing" + ); + assert_eq!( + vp["proof"]["challenge"].as_str(), + Some(id.to_string().as_str()), + "the copy the published task names is missing" + ); + assert_eq!(vp["holder"].as_str(), Some(MEMBER)); + } + + const CHALLENGE: &str = "6f1c4f9e-7c2a-4f4b-9a3e-2b1d0c5e8a77"; + + /// The reply as `vtc-service` actually sends it: the whole `#response` + /// document, task members nested under `payload`. + fn challenge_response_document(ext: Value) -> Value { + json!({ + "id": "urn:uuid:00000000-0000-4000-8000-000000000000", + "type": format!("{PERSONHOOD_CHALLENGE_TYPE}#response"), + "payload": { + "challengeId": CHALLENGE, + "expiresAt": "2026-08-24T10:15:00Z", + "ext": ext, + } + }) + } + + #[test] + fn challenge_reply_yields_the_nonce_and_the_spoken_code() { + let reply = parse_challenge_reply(&challenge_response_document( + json!({ "org.openvtc.match-code": "5CY1-GZEE" }), + )) + .expect("parse challenge reply"); + + assert_eq!(reply.challenge_id.to_string(), CHALLENGE); + assert_eq!(reply.match_code, "5CY1-GZEE"); + } + + /// A community that does not send its copy is fine — the code is derived + /// locally, so an older VTC costs nothing. + #[test] + fn challenge_reply_without_the_community_copy_still_derives_the_code() { + let mut doc = challenge_response_document(json!({})); + doc["payload"] + .as_object_mut() + .expect("payload object") + .remove("ext"); + + let reply = parse_challenge_reply(&doc).expect("parse challenge reply"); + assert_eq!(reply.match_code, "5CY1-GZEE"); + } + + /// A community whose copy *disagrees* is not fine. Both sides would + /// still show eight plausible characters, so without this the operator + /// concludes they have the wrong ceremony rather than that the two + /// implementations have drifted. + #[test] + fn challenge_reply_refuses_a_match_code_disagreement() { + let err = parse_challenge_reply(&challenge_response_document( + json!({ "org.openvtc.match-code": "0000-0000" }), + )) + .expect_err("a disagreement must not pass silently"); + + assert!( + err.to_string().contains("drifted"), + "the error should name the cause, got: {err}" + ); + } + + /// The bare-body shape, for a reply built outside the dispatcher. + #[test] + fn a_reply_without_a_payload_wrapper_is_read_whole() { + let bare = json!({ + "did": MEMBER, + "personhood": true, + "vmc": { "type": ["VerifiableCredential", "MembershipCredential", + "PersonhoodCredential"] }, + "roleVec": { "type": ["VerifiableCredential", "EndorsementCredential"] }, + }); + let reply = parse_assert_reply(&bare).expect("parse assert reply"); + + assert!(reply.personhood); + assert_eq!(reply.did, MEMBER); + assert_eq!( + reply.vmc["type"][2].as_str(), + Some("PersonhoodCredential"), + "the re-minted VMC is what carries the claim" + ); + } + + /// `nonce` is inside the signed body, so tampering with it invalidates + /// the proof — which is the entire reason it carries the challenge. + /// `proof.challenge` is outside it and cannot do this job alone. + #[tokio::test] + async fn the_signed_nonce_is_covered_by_the_proof() { + let id = Uuid::new_v4(); + let vp = build_presentation(&secret(), MEMBER, &id, vec![]) + .await + .expect("build presentation"); + + let proof: DataIntegrityProof = + serde_json::from_value(vp["proof"].clone()).expect("parse proof"); + let holder = secret(); + let pubkey = holder.get_public_bytes().to_vec(); + + let mut tampered = vp.clone(); + tampered.as_object_mut().unwrap().remove("proof"); + assert!( + proof + .verify_with_public_key( + &tampered, + &pubkey, + affinidi_data_integrity::VerifyOptions::new() + ) + .is_ok(), + "the untampered presentation must verify" + ); + + tampered["nonce"] = Value::String(Uuid::new_v4().to_string()); + assert!( + proof + .verify_with_public_key( + &tampered, + &pubkey, + affinidi_data_integrity::VerifyOptions::new() + ) + .is_err(), + "swapping the nonce must break the proof — otherwise the challenge is not bound \ + to anything and a captured presentation is replayable" + ); + } +} diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index 2186f7db..4d5dd748 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -260,6 +260,16 @@ pub enum Action { /// send it to the community's VTC over DIDComm (`members/vmc/1.0`). Indexed /// into the Communities display list. IssueMemberVmc(usize), + /// Ask the community at display index `usize` for a personhood challenge + /// (`members/personhood/challenge/0.1`). The reply arrives asynchronously + /// and lands as the panel's live challenge. + RequestPersonhoodChallenge(usize), + /// Answer the live personhood challenge (`members/personhood/assert/0.1`). + /// + /// Takes no index: the challenge already names the membership it belongs + /// to, and answering it against whichever row happens to be highlighted is + /// exactly the confusion the stored `vtc_did` exists to prevent. + AssertPersonhood, /// Open the capabilities view for the community at display index `usize` /// and fire the `governance/capability/list` query. CapabilitiesOpen(usize), diff --git a/openvtc/src/state_handler/community_actions.rs b/openvtc/src/state_handler/community_actions.rs index 13080252..3bff29bd 100644 --- a/openvtc/src/state_handler/community_actions.rs +++ b/openvtc/src/state_handler/community_actions.rs @@ -33,6 +33,32 @@ pub(crate) enum Verb { /// `members/vmc` — issue the reciprocal membership credential, signed by the /// member. IssueVmc { signing_secret: Box }, + /// `members/personhood/challenge` — ask for the nonce an assertion must + /// carry. The reply arrives asynchronously and lands via + /// [`crate::state_handler::message_dispatch`]; nothing here waits for it. + RequestPersonhoodChallenge, + /// `members/personhood/assert` — present the evidence over that nonce. + /// + /// `credentials` are presented whole: `eddsa-jcs-2022` credentials cannot + /// be redacted, so a member discloses each one entire or not at all. + AssertPersonhood { + signing_secret: Box, + challenge_id: uuid::Uuid, + credentials: Vec, + }, +} + +/// What a job did, for the apply path to report. +/// +/// Replaces the `leaving: bool` this carried when there were two verbs. A +/// boolean cannot say which of four things happened, and the arm that got it +/// wrong would report the wrong thing to the member rather than fail. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Performed { + Leave, + IssueVmc, + RequestPersonhoodChallenge, + AssertPersonhood, } /// Everything the send needs, resolved on the loop thread. @@ -49,7 +75,24 @@ pub(crate) struct CommunityJob { impl CommunityJob { /// Do the send. I/O only. pub(crate) async fn run(self) -> CommunityOutcome { - let leaving = matches!(self.verb, Verb::Leave); + let performed = match &self.verb { + Verb::Leave => Performed::Leave, + Verb::IssueVmc { .. } => Performed::IssueVmc, + Verb::RequestPersonhoodChallenge => Performed::RequestPersonhoodChallenge, + Verb::AssertPersonhood { .. } => Performed::AssertPersonhood, + }; + // The personhood verbs share one route; building it once keeps the + // member/community/mediator triple from being re-spelled per arm. + let route = openvtc_core::personhood::Route { + atm: &self.atm, + profile: &self.profile, + member_did: &self.member_did, + vtc_did: &self.vtc_did, + mediator_did: &self.mediator, + // TSP selection is the session's to make; this path sends over the + // established DIDComm leg, as the other community verbs do. + tsp_mediator_did: None, + }; let result = match &self.verb { Verb::Leave => openvtc_core::join::submit_self_remove( &self.atm, @@ -71,11 +114,31 @@ impl CommunityJob { ) .await .map(|_| ()), + Verb::RequestPersonhoodChallenge => { + // The member asks for their own. An administrator minting one + // for somebody else is a community-side action, not something + // this client offers. + openvtc_core::personhood::request_challenge(&route, &self.member_did) + .await + .map(|_| ()) + } + Verb::AssertPersonhood { + signing_secret, + challenge_id, + credentials, + } => openvtc_core::personhood::assert_personhood( + &route, + signing_secret, + challenge_id, + credentials.clone(), + ) + .await + .map(|_| ()), }; CommunityOutcome { vtc_did: self.vtc_did, persona: self.persona, - leaving, + performed, error: result.err().map(|e| format!("{e}")), } } @@ -85,8 +148,8 @@ impl CommunityJob { pub(crate) struct CommunityOutcome { vtc_did: String, persona: PersonaId, - /// `true` for a leave, whose success also changes the membership record. - leaving: bool, + /// Which verb ran — a leave also changes the membership record. + performed: Performed, error: Option, } @@ -108,8 +171,8 @@ impl CommunityOutcome { fn status(state: &mut State, msg: String) { state.main_page.content_panel.communities.status_message = Some(msg); } - match (self.error, self.leaving) { - (None, true) => { + match (self.error, self.performed) { + (None, Performed::Leave) => { // The record moves on the send, not on a receipt: the community's // acknowledgement is advisory, and a member who has announced a // departure should not still be shown as a member if it never @@ -122,17 +185,33 @@ impl CommunityOutcome { state.main_page.sync_from_config(config); return Some((self.vtc_did, self.persona)); } - (None, false) => { + (None, Performed::IssueVmc) => { status( state, "Membership credential issued and sent to the community.".to_string(), ); } - (Some(e), true) => { + (None, Performed::RequestPersonhoodChallenge) => { + // Only the *send* succeeded. The challenge itself arrives in + // the community's reply, so the message says what is being + // waited for rather than implying the ceremony has moved on. + status( + state, + "Asked the community for a personhood challenge — waiting for its reply." + .to_string(), + ); + } + (None, Performed::AssertPersonhood) => { + status( + state, + "Personhood assertion sent — waiting for the community's decision.".to_string(), + ); + } + (Some(e), Performed::Leave) => { state.main_page.log_error("Leave failed", e.as_str()); status(state, format!("Couldn't leave: {e}")); } - (Some(e), false) => { + (Some(e), Performed::IssueVmc) => { state .main_page .log_error("Issue membership credential failed", e.as_str()); @@ -141,6 +220,21 @@ impl CommunityOutcome { format!("Couldn't issue the membership credential: {e}"), ); } + (Some(e), Performed::RequestPersonhoodChallenge) => { + state + .main_page + .log_error("Personhood challenge request failed", e.as_str()); + status(state, format!("Couldn't ask for a challenge: {e}")); + } + (Some(e), Performed::AssertPersonhood) => { + // The challenge is single-use, but a send that never left does + // not spend it — so the member keeps the one they have and can + // retry rather than starting the ceremony again. + state + .main_page + .log_error("Personhood assertion failed", e.as_str()); + status(state, format!("Couldn't assert personhood: {e}")); + } } None } @@ -153,15 +247,25 @@ mod tests { const VTC: &str = "did:webvh:QmScidCommunity:example.com:acme"; - fn outcome(leaving: bool, error: Option<&str>) -> CommunityOutcome { + fn outcome(performed: Performed, error: Option<&str>) -> CommunityOutcome { CommunityOutcome { vtc_did: VTC.to_string(), persona: PersonaId(uuid::Uuid::nil()), - leaving, + performed, error: error.map(ToString::to_string), } } + /// Read the status line the panel would show. + fn status_of(state: &State) -> Option<&str> { + state + .main_page + .content_panel + .communities + .status_message + .as_deref() + } + /// A successful leave asks the loop to tear the session down. Nothing else /// can: the session manager is not reachable from the shared apply path. #[test] @@ -170,7 +274,7 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = outcome(true, None).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::Leave, None).apply(&mut state, &mut config, &mut save); assert_eq!(deregister.map(|(v, _)| v).as_deref(), Some(VTC)); assert!(save.is_pending(), "the record change must be persisted"); @@ -184,8 +288,11 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = - outcome(true, Some("peer unreachable")).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::Leave, Some("peer unreachable")).apply( + &mut state, + &mut config, + &mut save, + ); assert!(deregister.is_none()); assert!( @@ -207,7 +314,8 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = outcome(false, None).apply(&mut state, &mut config, &mut save); + let deregister = + outcome(Performed::IssueVmc, None).apply(&mut state, &mut config, &mut save); assert!(deregister.is_none()); assert!(!save.is_pending(), "nothing to persist"); @@ -229,8 +337,11 @@ mod tests { let mut config = test_config(); let mut save = SaveScheduler::new("test"); - let deregister = - outcome(false, Some("vault refused")).apply(&mut state, &mut config, &mut save); + let deregister = outcome(Performed::IssueVmc, Some("vault refused")).apply( + &mut state, + &mut config, + &mut save, + ); assert!(deregister.is_none()); assert!( @@ -241,4 +352,76 @@ mod tests { .any(|e| e.summary.contains("vault refused")), ); } + + /// A successful challenge request has **not** obtained a challenge — only + /// sent the ask. The reply carries the nonce, so a status line claiming + /// otherwise would have the member looking for a match code that has not + /// arrived. + #[test] + fn a_sent_challenge_request_says_it_is_waiting() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + outcome(Performed::RequestPersonhoodChallenge, None).apply( + &mut state, + &mut config, + &mut save, + ); + + let msg = status_of(&state).expect("a status line"); + assert!( + msg.contains("waiting"), + "the send is not the challenge; got: {msg}" + ); + assert!( + !save.is_pending(), + "asking for a challenge changes nothing worth persisting" + ); + } + + /// Likewise for the assertion: the community decides, and its decision + /// arrives later. + #[test] + fn a_sent_assertion_says_it_is_waiting_on_the_community() { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + outcome(Performed::AssertPersonhood, None).apply(&mut state, &mut config, &mut save); + + let msg = status_of(&state).expect("a status line"); + assert!( + msg.contains("waiting"), + "a sent assertion is not an asserted personhood; got: {msg}" + ); + } + + /// Each verb reports its own failure. With the previous `leaving: bool` + /// this could not be expressed — two of the four would have had to borrow + /// another's wording and tell the member the wrong thing went wrong. + #[test] + fn each_verb_reports_its_own_failure() { + for (performed, expected) in [ + (Performed::Leave, "Couldn't leave"), + (Performed::IssueVmc, "Couldn't issue"), + ( + Performed::RequestPersonhoodChallenge, + "Couldn't ask for a challenge", + ), + (Performed::AssertPersonhood, "Couldn't assert personhood"), + ] { + let mut state = State::default(); + let mut config = test_config(); + let mut save = SaveScheduler::new("test"); + + outcome(performed, Some("peer unreachable")).apply(&mut state, &mut config, &mut save); + + let msg = status_of(&state).expect("a status line"); + assert!( + msg.contains(expected), + "{performed:?} should report {expected:?}, got: {msg}" + ); + } + } } diff --git a/openvtc/src/state_handler/main_page/content.rs b/openvtc/src/state_handler/main_page/content.rs index 9db31bc5..b45637df 100644 --- a/openvtc/src/state_handler/main_page/content.rs +++ b/openvtc/src/state_handler/main_page/content.rs @@ -170,6 +170,42 @@ pub struct CommunitiesState { /// Whether archived communities are included in the list (R-C-8). Off by /// default; toggled so archived records stay discoverable. pub show_archived: bool, + /// The personhood challenge this member is part-way through answering, if + /// any. `Some` between the community's challenge reply arriving and the + /// assertion being sent or the challenge lapsing. + /// + /// Deliberately **not** persisted to the account. The challenge is + /// single-use with a ten-minute life, so a copy surviving a restart could + /// only ever be a stale one — and showing a member a match code the + /// community has already forgotten is worse than showing none. + pub personhood_challenge: Option, +} + +/// A live personhood challenge, as the panel shows it. +#[derive(Clone, Debug)] +pub struct PersonhoodChallengeView { + /// Which membership it belongs to. A member may hold several, and a + /// challenge minted for one community means nothing to another. + pub vtc_did: String, + pub persona: openvtc_core::config::account::PersonaId, + /// The nonce the presentation must carry. + pub challenge_id: uuid::Uuid, + /// The eight characters to read aloud, derived from `challenge_id`. + pub match_code: String, + /// When the community stops accepting a presentation for it. + pub expires_at: chrono::DateTime, +} + +impl PersonhoodChallengeView { + /// Whether the community would still accept a presentation for this. + /// + /// The panel checks at render time rather than on a timer: a lapsed + /// challenge should stop offering to be answered the moment a person looks + /// at it, and the alternative — a countdown task per challenge — is state + /// to keep in sync for no gain. + pub fn is_live(&self, now: chrono::DateTime) -> bool { + now < self.expires_at + } } /// Quick community-switcher overlay state (R-C-7). `Some` while the Ctrl+K popup diff --git a/openvtc/src/state_handler/message_dispatch.rs b/openvtc/src/state_handler/message_dispatch.rs index 54304790..423d4330 100644 --- a/openvtc/src/state_handler/message_dispatch.rs +++ b/openvtc/src/state_handler/message_dispatch.rs @@ -20,6 +20,9 @@ use openvtc_core::messaging::{ handle_join_submit_receipt, handle_join_trust_task_error, handle_join_verdict, is_trust_task_error_type, require_thid, validate_did, verify_vrc_proof, vet_vrc_issued, }; +use openvtc_core::personhood::{ + PERSONHOOD_ASSERT_RESPONSE_TYPE, PERSONHOOD_CHALLENGE_RESPONSE_TYPE, +}; use openvtc_core::{ MessageType, config::{Config, account::VtcDid}, @@ -77,6 +80,26 @@ pub(crate) async fn issue_member_vmc_for( .await } +/// What an inbound message asks the loop to do, beyond mutating `Config`. +/// +/// These are things this function cannot do itself: tearing down a session +/// needs the session manager, and the live personhood challenge belongs to +/// `State` rather than to the account. Collected here rather than as three +/// trailing `&mut Vec` parameters — same reason the community verbs grew a +/// `Performed` enum, and it keeps the signature within clippy's argument +/// budget as more effects arrive. +#[derive(Default)] +pub struct InboundEffects { + /// Communities that resolved to an inactive status; the loop deregisters + /// their sessions (R-S-3). + pub inactivated: Vec<(VtcDid, openvtc_core::config::account::PersonaId)>, + /// Capability replies, keyed by the request id they thread on. + pub capability_replies: Vec<(String, openvtc_core::capabilities::CapabilityReply)>, + /// Personhood challenges the community answered with. Display state with a + /// ten-minute life — never persisted. + pub personhood_challenges: Vec, +} + /// Process an inbound DIDComm message. /// /// Auto-processes messages that don't need human input (pong, accept, finalize, reject). @@ -89,9 +112,13 @@ pub async fn process_inbound_message( service: &Messaging, seen: &mut SeenMessages, message: &Message, - inactivated: &mut Vec<(VtcDid, openvtc_core::config::account::PersonaId)>, - capability_replies: &mut Vec<(String, openvtc_core::capabilities::CapabilityReply)>, + effects: &mut InboundEffects, ) -> Result { + let InboundEffects { + inactivated, + capability_replies, + personhood_challenges, + } = effects; // Drop messages outside the replay / freshness window before doing // any state-mutating work. Saves us from acting on stale captures // and from clock-skew–induced retries. @@ -270,6 +297,46 @@ pub async fn process_inbound_message( return Ok(outcome.changed); } + // VTC personhood challenge reply: carries the nonce an assertion must be + // signed over, and the match code to read aloud. Reported up rather than + // applied here — the challenge is display state with a ten-minute life, + // and this function owns `Config`, which is the thing that gets persisted. + // A single-use nonce has no business surviving a restart. + if message.typ == PERSONHOOD_CHALLENGE_RESPONSE_TYPE { + match openvtc_core::personhood::parse_challenge_reply(&message.body) { + Ok(reply) => { + debug!( + vtc = %from_did, + challenge = %reply.challenge_id, + "personhood challenge received", + ); + personhood_challenges.push(reply); + } + // Includes the match-code disagreement, which is a real finding + // rather than a malformed message: it means this client and the + // community derive different codes from the same challenge, so + // the spoken confirmation would be meaningless. + Err(e) => warn!(vtc = %from_did, "unusable personhood challenge reply: {e}"), + } + return Ok(false); + } + + // VTC personhood assertion result. The re-issued VMC carrying + // `PersonhoodCredential` arrives separately as a credential-issue message, + // which is what actually updates the wallet — this is the decision. + if message.typ == PERSONHOOD_ASSERT_RESPONSE_TYPE { + match openvtc_core::personhood::parse_assert_reply(&message.body) { + Ok(reply) => info!( + vtc = %from_did, + did = %reply.did, + personhood = reply.personhood, + "personhood asserted", + ), + Err(e) => warn!(vtc = %from_did, "unusable personhood assert reply: {e}"), + } + return Ok(false); + } + // VTC member-VMC receipt (`members/vmc/1.0#response`): the VTC acknowledging // the reciprocal VMC we sent. Informational — log and move on. if message.typ == MEMBER_VMC_RESPONSE_TYPE { diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index bcb30446..67d1bfb7 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -1117,18 +1117,62 @@ impl StateHandler { let msg_to = message.to.as_ref().and_then(|v| v.first()).cloned().unwrap_or_default(); let msg_thid = message.thid.clone().unwrap_or_else(|| "none".into()); - let mut inactivated = Vec::new(); - let mut capability_replies = Vec::new(); - match message_dispatch::process_inbound_message( + let mut effects = message_dispatch::InboundEffects::default(); + let dispatched = message_dispatch::process_inbound_message( &mut config, &tdk, &didcomm_service, &mut seen_messages, &message, - &mut inactivated, - &mut capability_replies, + &mut effects, ) - .await + .await; + let message_dispatch::InboundEffects { + inactivated, + capability_replies, + personhood_challenges, + } = effects; + + // A live challenge is display state, not account + // state: single-use, ten-minute life, and worthless + // after a restart. It is folded in here rather than + // persisted, and unconditionally — a reply that + // arrived is a reply the member should see whether + // or not the message also changed the config. + for reply in personhood_challenges { + // The challenge belongs to the persona it was + // addressed to. Without one we cannot sign for + // it, so there is nothing to offer the member — + // and silently showing an unanswerable code + // would be worse than saying nothing. + let Some(persona) = config.account.persona_id_for_did(&msg_to) + else { + warn!( + vtc = %msg_from, + to = %msg_to, + "personhood challenge addressed to a DID this account \ + holds no persona for — ignoring", + ); + continue; + }; + state.main_page.content_panel.communities.personhood_challenge = + Some(crate::state_handler::main_page::content::PersonhoodChallengeView { + vtc_did: msg_from.clone(), + persona, + challenge_id: reply.challenge_id, + match_code: reply.match_code.clone(), + expires_at: reply.expires_at, + }); + state.main_page.content_panel.communities.status_message = Some( + format!( + "Personhood challenge received — confirm the code {} with \ + whoever is vetting you, then assert.", + reply.match_code + ), + ); + } + + match dispatched { Ok(true) => { // R11: a config-mutating inbound message used @@ -2129,6 +2173,7 @@ impl StateHandler { // silent — a dead key is indistinguishable from a broken one. Action::Inbox(..) | Action::Relationship(..) | Action::Credential(..) | Action::IssueMemberVmc(..) | Action::CapabilitiesOpen(..) | + Action::RequestPersonhoodChallenge(..) | Action::AssertPersonhood | Action::CapabilitiesRefresh | Action::CapabilitiesToggleCommit | Action::SetActiveCommunity(..) | Action::ToggleFavourite(..) | Action::AcknowledgeCommunity(..) | Action::LeaveCommunity(..) | diff --git a/openvtc/src/state_handler/runtime_actions.rs b/openvtc/src/state_handler/runtime_actions.rs index 93264e47..1d92fa6f 100644 --- a/openvtc/src/state_handler/runtime_actions.rs +++ b/openvtc/src/state_handler/runtime_actions.rs @@ -440,6 +440,123 @@ pub(crate) async fn handle_action(ctx: &mut ActionCtx<'_>, action: Action) -> Ha } } } + Action::RequestPersonhoodChallenge(i) => { + // Ask the community for the nonce an assertion must carry. No key + // is needed yet — nothing is signed until the challenge comes back. + let target = ctx + .config + .account + .communities_for_display( + ctx.state.main_page.content_panel.communities.show_archived, + ) + .get(i) + .filter(|c| c.status.is_active()) + .map(|c| (c.vtc_did.clone(), c.persona_ref)); + if let Some((vtc, persona_id)) = target { + match capability_sender(ctx.config, ctx.tdk, persona_id) { + Some((atm, profile, member_did, mediator)) => spawn_community_job( + ctx.dispatch_tx, + ctx.in_flight, + ctx.state, + community_actions::CommunityJob { + atm, + profile, + member_did, + mediator, + vtc_did: vtc, + persona: persona_id, + verb: community_actions::Verb::RequestPersonhoodChallenge, + }, + ), + None => { + ctx.state.main_page.content_panel.communities.status_message = Some( + "Messaging unavailable — cannot ask for a challenge right now." + .to_string(), + ); + } + } + } + } + Action::AssertPersonhood => { + // The challenge names its own membership, so the target comes from + // the challenge rather than from whichever row is highlighted. + let challenge = ctx + .state + .main_page + .content_panel + .communities + .personhood_challenge + .clone(); + let Some(challenge) = challenge else { + ctx.state.main_page.content_panel.communities.status_message = + Some("No personhood challenge in hand — ask for one first.".to_string()); + return Handled::Continue; + }; + if !challenge.is_live(chrono::Utc::now()) { + // Drop it rather than send: the community would refuse it, and + // leaving a dead challenge on screen invites a second attempt + // that fails the same way. + ctx.state + .main_page + .content_panel + .communities + .personhood_challenge = None; + ctx.state.main_page.content_panel.communities.status_message = + Some("That personhood challenge expired — ask for a fresh one.".to_string()); + return Handled::Continue; + } + + // Present the credentials this community itself issued to this + // membership and let its policy choose among them. A client-side + // filter would have to guess at a policy it cannot read, and + // guessing wrong means withholding the very credential that would + // have satisfied it — the identity-verification endorsement an + // administrator issued after vetting the member in person. + // + // Scoped to this membership rather than the whole wallet: another + // community's credentials are not evidence here, and sending them + // would disclose one community's membership to another. + let credentials: Vec = ctx + .config + .account + .membership(&challenge.vtc_did, challenge.persona) + .map(|m| m.credentials.values().cloned().collect()) + .unwrap_or_default(); + + match ( + capability_sender(ctx.config, ctx.tdk, challenge.persona), + ctx.config + .get_persona_keys_for(challenge.persona, ctx.tdk) + .await, + ) { + (Some((atm, profile, member_did, mediator)), Ok(keys)) => spawn_community_job( + ctx.dispatch_tx, + ctx.in_flight, + ctx.state, + community_actions::CommunityJob { + atm, + profile, + member_did, + mediator, + vtc_did: challenge.vtc_did.clone(), + persona: challenge.persona, + verb: community_actions::Verb::AssertPersonhood { + signing_secret: Box::new(keys.signing.secret.clone()), + challenge_id: challenge.challenge_id, + credentials, + }, + }, + ), + (_, Err(e)) => { + ctx.state.main_page.content_panel.communities.status_message = + Some(format!("Couldn't sign the personhood presentation: {e}")); + } + (None, _) => { + ctx.state.main_page.content_panel.communities.status_message = + Some("Messaging unavailable — cannot assert right now.".to_string()); + } + } + } Action::WithdrawJoin(i) => { // Cancel a Pending join: best-effort notify the VTC, set // the record `Withdrawn`, and tear down its now-dead diff --git a/openvtc/src/ui/pages/main/components/communities_panel.rs b/openvtc/src/ui/pages/main/components/communities_panel.rs index 229d1e56..cb284b64 100644 --- a/openvtc/src/ui/pages/main/components/communities_panel.rs +++ b/openvtc/src/ui/pages/main/components/communities_panel.rs @@ -38,6 +38,8 @@ pub fn render(state: &CommunitiesState) -> Vec> { lines.push(Line::from("")); } + push_personhood_challenge(&mut lines, state); + if state.items.is_empty() { return render_empty(lines); } @@ -243,6 +245,41 @@ pub fn render(state: &CommunitiesState) -> Vec> { lines } +/// Show the live personhood challenge, if there is one. +/// +/// The match code is the point of this block. It is the thing a member reads +/// aloud to whoever is vetting them, so it is rendered on its own line, spaced, +/// and in the panel's emphasis colour rather than folded into the status text — +/// a code that has to be picked out of a sentence is a code that gets misread. +/// +/// A lapsed challenge renders as lapsed rather than vanishing. Silently +/// removing it would leave a member who has just been read a code looking at a +/// panel that never mentioned one, with no way to tell that time ran out from +/// never having received it. +fn push_personhood_challenge(lines: &mut Vec>, state: &CommunitiesState) { + let Some(challenge) = &state.personhood_challenge else { + return; + }; + + if challenge.is_live(chrono::Utc::now()) { + lines.push(Line::from(" Personhood challenge").fg(COLOR_DARK_GRAY)); + lines.push( + Line::from(format!(" {}", challenge.match_code)) + .style(Style::new().fg(COLOR_SOFT_PURPLE).bold()), + ); + lines.push( + Line::from(" Confirm this code with whoever is vetting you, then press P.") + .fg(COLOR_DARK_GRAY), + ); + } else { + lines.push( + Line::from(" Personhood challenge expired — press p for a fresh one.") + .fg(COLOR_DARK_GRAY), + ); + } + lines.push(Line::from("")); +} + /// The key hints for the selected row, gated exactly as the key handler gates /// the keys themselves (`ui::pages::main::handle_communities_key`). /// @@ -272,6 +309,7 @@ fn key_hints(state: &CommunitiesState) -> String { if community.is_active { hints.push("m: issue VMC".to_string()); hints.push("c: capabilities".to_string()); + hints.push("p: personhood".to_string()); hints.push("l: leave".to_string()); } if community.is_pending { @@ -283,6 +321,18 @@ fn key_hints(state: &CommunitiesState) -> String { } } + // Gated on the challenge, not on the row — matching the key handler, + // which offers `P` only while there is something to answer. A live + // challenge belongs to the membership that asked for it, so this stays + // offered while the member navigates the list. + if state + .personhood_challenge + .as_ref() + .is_some_and(|c| c.is_live(chrono::Utc::now())) + { + hints.push("P: assert personhood".to_string()); + } + hints.push("j: join".to_string()); hints.push( if state.show_archived { @@ -431,4 +481,108 @@ mod key_hint_tests { }); assert!(hints.contains("v: hide archived"), "{hints}"); } + + // ─── personhood ────────────────────────────────────────────────────── + + use crate::state_handler::main_page::content::PersonhoodChallengeView; + use openvtc_core::config::account::PersonaId; + + fn challenge(expires_in_minutes: i64) -> PersonhoodChallengeView { + PersonhoodChallengeView { + vtc_did: "did:webvh:acme".to_string(), + persona: PersonaId(uuid::Uuid::nil()), + challenge_id: uuid::Uuid::nil(), + match_code: "5CY1-GZEE".to_string(), + expires_at: chrono::Utc::now() + chrono::Duration::minutes(expires_in_minutes), + } + } + + fn state_with( + community: Option, + c: Option, + ) -> CommunitiesState { + CommunitiesState { + items: Arc::from(community.map(|x| vec![x]).unwrap_or_default()), + selected_index: 0, + personhood_challenge: c, + ..CommunitiesState::default() + } + } + + /// `p` is Active-only, matching the key handler. Offering it on a Pending + /// row would be a silent no-op — the defect this file's hint gating was + /// written to remove. + #[test] + fn personhood_is_offered_only_on_an_active_row() { + assert!(hints_for(row(true, false, false)).contains("p: personhood")); + for (active, inactive, pending) in [(false, true, false), (false, false, true)] { + let hints = hints_for(row(active, inactive, pending)); + assert!( + !hints.contains("p: personhood"), + "personhood needs an Active membership: {hints}" + ); + } + } + + /// `P` is gated on holding a live challenge, not on the row — exactly as + /// the key handler gates it. Advertising it with nothing to answer would + /// be the same class of dead key. + #[test] + fn assert_is_offered_only_while_a_live_challenge_is_held() { + let active = row(true, false, false); + + assert!( + !key_hints(&state_with(Some(active.clone()), None)).contains("P: assert"), + "nothing to assert against" + ); + assert!( + key_hints(&state_with(Some(active.clone()), Some(challenge(5)))) + .contains("P: assert personhood"), + ); + assert!( + !key_hints(&state_with(Some(active), Some(challenge(-1)))).contains("P: assert"), + "an expired challenge cannot be answered" + ); + } + + /// The match code is what a member reads aloud, so it has to be on screen + /// — and on its own line rather than buried in a sentence. + #[test] + fn a_live_challenge_shows_its_match_code() { + let rendered: Vec = render(&state_with( + Some(row(true, false, false)), + Some(challenge(5)), + )) + .iter() + .map(|l| l.to_string()) + .collect(); + + assert!( + rendered.iter().any(|l| l.trim() == "5CY1-GZEE"), + "the code must stand alone: {rendered:#?}" + ); + } + + /// An expired challenge says so rather than disappearing. A member who has + /// just been read a code, looking at a panel that never mentions one, + /// cannot tell "it lapsed" from "it never arrived". + #[test] + fn an_expired_challenge_says_so_rather_than_vanishing() { + let rendered: Vec = render(&state_with( + Some(row(true, false, false)), + Some(challenge(-1)), + )) + .iter() + .map(|l| l.to_string()) + .collect(); + + assert!( + rendered.iter().any(|l| l.contains("expired")), + "the lapse must be visible: {rendered:#?}" + ); + assert!( + !rendered.iter().any(|l| l.contains("5CY1-GZEE")), + "a dead code must not still read as answerable: {rendered:#?}" + ); + } } diff --git a/openvtc/src/ui/pages/main/mod.rs b/openvtc/src/ui/pages/main/mod.rs index 4c50838c..17520f6b 100644 --- a/openvtc/src/ui/pages/main/mod.rs +++ b/openvtc/src/ui/pages/main/mod.rs @@ -684,6 +684,28 @@ impl MainPage { .send(Action::CommunityConfirmWithdraw(selected)); true } + KeyCode::Char('p') if sel_active => { + // Ask this community for a personhood challenge. Active-only: + // a community that has not admitted us has no personhood state + // to assert against. + let _ = self + .action_tx + .send(Action::RequestPersonhoodChallenge(selected)); + true + } + // Assert is offered only while a live challenge is in hand. + // Gating on the challenge rather than on the row means the key does + // nothing surprising when there is nothing to answer — and the + // panel only advertises it in the same condition. + KeyCode::Char('P') + if comms + .personhood_challenge + .as_ref() + .is_some_and(|c| c.is_live(chrono::Utc::now())) => + { + let _ = self.action_tx.send(Action::AssertPersonhood); + true + } KeyCode::Char('x') if sel_inactive => { // Archive an inactive community (R-C-8). let _ = self.action_tx.send(Action::ArchiveCommunity(selected));