From eb436cb6d08c27be35b65a36a04b5faadcbb8b0b Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 17 Jun 2026 20:36:09 +0800 Subject: [PATCH] feat(join): join-as-subject + paste-a-VIC; linkage-proof groundwork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1a Join as the VIC subject: when a loaded/pasted invitation is bound to one of our personas, the join flow presents that persona automatically (holder-binding then matches at the VTC — presenter == VIC subject — with no linkage proof). #3 Paste a VIC into the TUI: pasting a JSON object on the join entry page is treated as an invitation credential — validated (must be an InvitationCredential) and stashed into the join flow, mirroring the `--invitation ` flag. The entry page shows a paste hint, and the loaded-invitation indicator on success. openvtc-core groundwork for the linkage path (#1b): `build_join_vp` now takes an optional `SubjectLinkage`; added `SUBJECT_LINKAGE_DOMAIN_TAG` (matches the VTC), `subject_linkage_signing_bytes`, and `invitation_subject` / `invitation_id` extractors. Unit-tested. Remaining (groundwork in place; VTC + SDK already support both): build the subject-linkage signature in the join flow for a fresh presenting DID (#1b), and load/store the VIC via the VTA credential vault (cred_vault_*) instead of the file/paste path (#2). Signed-off-by: Glenn Gore --- openvtc-core/src/join.rs | 113 ++++++++++++++++-- openvtc/src/state_handler/actions/mod.rs | 4 + openvtc/src/state_handler/join_flow.rs | 75 +++++++++++- openvtc/src/ui/pages/join_flow/mod.rs | 13 +- .../src/ui/pages/join_flow/vtc_enter_did.rs | 6 + 5 files changed, 197 insertions(+), 14 deletions(-) diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index be28bd2..9ad55ab 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -118,7 +118,11 @@ pub async fn submit_self_remove( /// `invitation` is the signed VIC as received out-of-band (a Data-Integrity VC, /// object form with its own `proof`). When `None`, the VP carries no /// credentials and the join falls to the community's other evidence / review. -pub fn build_join_vp(holder_did: &str, invitation: Option<&Value>) -> Value { +pub fn build_join_vp( + holder_did: &str, + invitation: Option<&Value>, + linkage: Option<&SubjectLinkage>, +) -> Value { let mut vp = serde_json::json!({ "type": "VerifiablePresentation", "holder": holder_did, @@ -126,38 +130,129 @@ pub fn build_join_vp(holder_did: &str, invitation: Option<&Value>) -> Value { if let Some(vic) = invitation { vp["verifiableCredential"] = Value::Array(vec![vic.clone()]); } + // Subject-linkage proof (#1b): present a VIC bound to a *different* DID by + // proving that DID authorized this holder. Omitted on the join-as-subject + // path (holder == VIC subject). + if let Some(l) = linkage { + vp["subjectLinkage"] = serde_json::json!({ + "verificationMethod": l.verification_method, + "signature": l.signature_hex, + }); + } vp } +/// Domain tag the VIC subject signs over for a subject-linkage proof. **Must +/// match `vtc-service`'s `SUBJECT_LINKAGE_DOMAIN_TAG`** byte-for-byte. +pub const SUBJECT_LINKAGE_DOMAIN_TAG: &[u8] = b"vtc-invitation-subject-linkage/v1\0"; + +/// A subject-linkage proof: the VIC subject's key signed +/// [`subject_linkage_signing_bytes`], authorizing a different presenter to +/// redeem the invitation. +#[derive(Debug, Clone)] +pub struct SubjectLinkage { + /// The VIC subject's verification method (`#`). + pub verification_method: String, + /// Hex-encoded Ed25519 signature over [`subject_linkage_signing_bytes`]. + pub signature_hex: String, +} + +/// The exact bytes a subject-linkage proof signs: +/// `SUBJECT_LINKAGE_DOMAIN_TAG || vic_id || NUL || presenter_did`. The VTC +/// rebuilds these identically when verifying, so both sides must agree. +pub fn subject_linkage_signing_bytes(vic_id: &str, presenter_did: &str) -> Vec { + let mut bytes = SUBJECT_LINKAGE_DOMAIN_TAG.to_vec(); + bytes.extend_from_slice(vic_id.as_bytes()); + bytes.push(0); + bytes.extend_from_slice(presenter_did.as_bytes()); + bytes +} + +/// The DID a VIC is bound to (`credentialSubject.id`). +pub fn invitation_subject(vic: &Value) -> Option<&str> { + vic.pointer("/credentialSubject/id").and_then(Value::as_str) +} + +/// A VIC's top-level `id` (its consumption / linkage handle). +pub fn invitation_id(vic: &Value) -> Option<&str> { + vic.get("id").and_then(Value::as_str) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + fn sample_vic() -> Value { + json!({ + "id": "urn:uuid:vic-1", + "type": ["VerifiableCredential", "InvitationCredential"], + "issuer": "did:webvh:example.com:community", + "credentialSubject": { "id": "did:webvh:example.com:alice" }, + "proof": { "type": "DataIntegrityProof" } + }) + } + #[test] fn vp_without_invitation_is_holder_only() { - let vp = build_join_vp("did:webvh:example.com:alice", None); + let vp = build_join_vp("did:webvh:example.com:alice", None, None); assert_eq!(vp["type"], "VerifiablePresentation"); assert_eq!(vp["holder"], "did:webvh:example.com:alice"); assert!( vp.get("verifiableCredential").is_none(), "no invitation → no credentials array" ); + assert!(vp.get("subjectLinkage").is_none()); } #[test] fn vp_with_invitation_embeds_the_vic() { - let vic = json!({ - "type": ["VerifiableCredential", "InvitationCredential"], - "issuer": "did:webvh:example.com:community", - "credentialSubject": { "id": "did:webvh:example.com:alice" }, - "proof": { "type": "DataIntegrityProof" } - }); - let vp = build_join_vp("did:webvh:example.com:alice", Some(&vic)); + let vic = sample_vic(); + let vp = build_join_vp("did:webvh:example.com:alice", Some(&vic), None); let creds = vp["verifiableCredential"] .as_array() .expect("verifiableCredential is an array"); assert_eq!(creds.len(), 1); assert_eq!(creds[0], vic, "the VIC is embedded verbatim"); + assert!( + vp.get("subjectLinkage").is_none(), + "no linkage on the join-as-subject path" + ); + } + + #[test] + fn vp_with_linkage_embeds_the_proof() { + let vic = sample_vic(); + let linkage = SubjectLinkage { + verification_method: "did:webvh:example.com:alice#key-0".into(), + signature_hex: "deadbeef".into(), + }; + let vp = build_join_vp("did:key:zFreshB", Some(&vic), Some(&linkage)); + assert_eq!( + vp["subjectLinkage"]["verificationMethod"], + "did:webvh:example.com:alice#key-0" + ); + assert_eq!(vp["subjectLinkage"]["signature"], "deadbeef"); + } + + #[test] + fn subject_and_id_extractors() { + let vic = sample_vic(); + assert_eq!( + invitation_subject(&vic), + Some("did:webvh:example.com:alice") + ); + assert_eq!(invitation_id(&vic), Some("urn:uuid:vic-1")); + assert_eq!(invitation_subject(&json!({})), None); + } + + #[test] + fn linkage_signing_bytes_are_tag_id_nul_presenter() { + let bytes = subject_linkage_signing_bytes("urn:uuid:vic-1", "did:key:zB"); + let mut expected = SUBJECT_LINKAGE_DOMAIN_TAG.to_vec(); + expected.extend_from_slice(b"urn:uuid:vic-1"); + expected.push(0); + expected.extend_from_slice(b"did:key:zB"); + assert_eq!(bytes, expected); } } diff --git a/openvtc/src/state_handler/actions/mod.rs b/openvtc/src/state_handler/actions/mod.rs index e62237b..75b24c8 100644 --- a/openvtc/src/state_handler/actions/mod.rs +++ b/openvtc/src/state_handler/actions/mod.rs @@ -252,6 +252,10 @@ pub enum Action { /// Cancel the join flow and return to the main page. JoinCancel, + /// Pasted text on the join entry page that looks like an invitation + /// credential (VIC) JSON — validated + stashed into the join flow. + JoinPasteVic(String), + /// Move the Communities-list selection to this index. CommunitySelect(usize), diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index 4760730..acdd9fe 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -31,7 +31,7 @@ use crate::{ actions::Action, join::{JoinPage, JoinState, PersonaOption}, main_page::shorten_did, - setup_sequence::{Completion, config::ConfigExtension, vta}, + setup_sequence::{Completion, MessageType, config::ConfigExtension, vta}, state::{ActivePage, State}, }, }; @@ -109,6 +109,25 @@ impl StateHandler { state.active_page = ActivePage::Main; return Ok(JoinExit::Returned(joined_session(&state.join))); } + Action::JoinPasteVic(text) => { + // #3: a pasted invitation credential — validate it is a + // VIC and stash it so the join presents it (mirrors the + // `--invitation ` launch flag). + match serde_json::from_str::(&text) { + Ok(vic) if is_invitation_credential(&vic) => { + state.invitation_credential = Some(vic); + state.join.has_invitation = true; + state.join.messages.clear(); + } + _ => { + state.join.messages.push(MessageType::Error( + "Pasted text is not a valid invitation credential." + .to_string(), + )); + } + } + let _ = self.state_tx.send(state.clone()); + } Action::JoinSubmitVtc(vtc_did) => { let Some(vtc_did) = validate_join_input(&vtc_did) else { continue; @@ -124,6 +143,28 @@ impl StateHandler { let _ = self.state_tx.send(state.clone()); continue; } + // #1a join-as-subject: if a loaded invitation is bound + // to one of our personas, present that persona + // automatically — holder-binding then matches at the + // VTC (presenter == VIC subject) with no linkage proof. + if let Some(pid) = invitation_subject_persona(config, state) { + if let Some(interrupted) = self + .launch_join_sequence( + JoinIdentityChoice::Reuse(pid), + vtc_did, + interrupt_rx, + state, + tdk, + config, + admin_vta, + profile, + ) + .await + { + return Ok(JoinExit::Exit(interrupted)); + } + continue; + } // R-B-3 / D1: with existing personas, let the user choose // to reuse one or mint a fresh identity; with none (the // first join), there's nothing to reuse — mint directly. @@ -358,6 +399,28 @@ fn validate_join_input(raw: &str) -> Option { } } +/// #1a: the existing persona a loaded invitation is bound to, if any. When the +/// VIC's `credentialSubject.id` matches one of our personas, the join can +/// present that persona directly (holder-binding satisfied, no linkage proof). +fn invitation_subject_persona(config: &Config, state: &State) -> Option { + let vic = state.invitation_credential.as_ref()?; + let subject = openvtc_core::join::invitation_subject(vic)?; + config.account.persona_id_for_did(subject) +} + +/// Whether a pasted/loaded JSON value is an InvitationCredential (its `type` +/// array carries the `InvitationCredential` tag). +fn is_invitation_credential(value: &serde_json::Value) -> bool { + value + .get("type") + .and_then(|t| t.as_array()) + .is_some_and(|types| { + types + .iter() + .any(|t| t.as_str() == Some("InvitationCredential")) + }) +} + /// Idempotency decision (R-B-9): is there already a *live* (Active/Pending) /// membership for this VTC? /// @@ -668,8 +731,14 @@ async fn run_join_sequence( .info("Presenting your invitation credential to the community…"); let _ = handler.state_tx.send(state.clone()); } - let vp = - openvtc_core::join::build_join_vp(&applicant_did, state.invitation_credential.as_ref()); + // Subject-linkage (#1b) is built only when the presenting DID differs from + // the VIC subject. On the join-as-subject path (#1a) — where this persona is + // the invited DID — no linkage is needed and `None` is passed. + let vp = openvtc_core::join::build_join_vp( + &applicant_did, + state.invitation_credential.as_ref(), + None, + ); let request_id = match openvtc_core::join::submit_join_request( atm, &persona_profile, diff --git a/openvtc/src/ui/pages/join_flow/mod.rs b/openvtc/src/ui/pages/join_flow/mod.rs index 540ac2d..093a2b9 100644 --- a/openvtc/src/ui/pages/join_flow/mod.rs +++ b/openvtc/src/ui/pages/join_flow/mod.rs @@ -100,9 +100,18 @@ impl Component for JoinFlow { } fn handle_paste_event(&mut self, text: &str) { - // Paste the whole DID at once (instant for long did:webvh strings). if self.props.state.page == JoinPage::EnterDid && !self.props.state.processing { - self.vtc_did = Input::new(text.trim().to_string()); + let trimmed = text.trim(); + // A pasted JSON object is treated as an invitation credential (VIC): + // hand it to the state handler to validate + stash (#3). Anything + // else is the VTC DID being pasted into the input. + if trimmed.starts_with('{') { + let _ = self + .action_tx + .send(Action::JoinPasteVic(trimmed.to_string())); + } else { + self.vtc_did = Input::new(trimmed.to_string()); + } } } } diff --git a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs index 28d4ba0..00a1338 100644 --- a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs +++ b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs @@ -122,6 +122,12 @@ impl VtcEnterDid { Style::new().fg(COLOR_SOFT_PURPLE).bold(), )); lines.push(Line::default()); + } else { + lines.push(Line::styled( + "Tip: paste an invitation credential (VIC) here to auto-join.", + Style::new().fg(COLOR_DARK_GRAY).italic(), + )); + lines.push(Line::default()); } lines.push(Line::from(vec![ Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()),