Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 104 additions & 9 deletions openvtc-core/src/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,46 +118,141 @@ 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,
});
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 (`<subjectDid>#<key>`).
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<u8> {
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);
}
}
4 changes: 4 additions & 0 deletions openvtc/src/state_handler/actions/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),

Expand Down
75 changes: 72 additions & 3 deletions openvtc/src/state_handler/join_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
};
Expand Down Expand Up @@ -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 <file>` launch flag).
match serde_json::from_str::<serde_json::Value>(&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;
Expand All @@ -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.
Expand Down Expand Up @@ -358,6 +399,28 @@ fn validate_join_input(raw: &str) -> Option<String> {
}
}

/// #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<PersonaId> {
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?
///
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 11 additions & 2 deletions openvtc/src/ui/pages/join_flow/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions openvtc/src/ui/pages/join_flow/vtc_enter_did.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Loading