diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index d0f7d61..be28bd2 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -105,3 +105,59 @@ pub async fn submit_self_remove( crate::pack_and_send(atm, profile, &msg, member_did, vtc_did, mediator_did).await?; Ok(msg_id) } + +/// Build the holder presentation (VP) for a join request. +/// +/// The VTC's raw-VP submit path performs no VP-level proof check — the DIDComm +/// authcrypt sender authenticates the applicant — so the VP is a plain JSON +/// object naming the `holder`. When the applicant holds a Verifiable Invitation +/// Credential (VIC), it is embedded in the `verifiableCredential` array; the +/// VTC extracts it, verifies its issuer signature + holder-binding, and (per the +/// default `join.rego`) auto-admits on a valid, trusted, unconsumed invitation. +/// +/// `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 { + let mut vp = serde_json::json!({ + "type": "VerifiablePresentation", + "holder": holder_did, + }); + if let Some(vic) = invitation { + vp["verifiableCredential"] = Value::Array(vec![vic.clone()]); + } + vp +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn vp_without_invitation_is_holder_only() { + let vp = build_join_vp("did:webvh:example.com:alice", 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" + ); + } + + #[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 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"); + } +} diff --git a/openvtc/src/cli.rs b/openvtc/src/cli.rs index 546f739..fea17f3 100644 --- a/openvtc/src/cli.rs +++ b/openvtc/src/cli.rs @@ -26,6 +26,14 @@ pub fn cli() -> Command { .long("profile") .help("Config profile to use") .default_value("default"), + Arg::new("invitation") + .long("invitation") + .value_name("FILE") + .help( + "Path to a Verifiable Invitation Credential (VIC) JSON file \ + to present when joining a community. The community verifies \ + it and auto-admits on a valid, trusted, unconsumed invitation.", + ), ]) .subcommand(Command::new("setup").about("Initial configuration of the openvtc tool")) } diff --git a/openvtc/src/main.rs b/openvtc/src/main.rs index 6af8059..b78f167 100644 --- a/openvtc/src/main.rs +++ b/openvtc/src/main.rs @@ -103,6 +103,22 @@ async fn main() -> Result<()> { let unlock_code_arg = matches.get_one::("unlock-code").cloned(); let setup_requested = matches!(matches.subcommand(), Some(("setup", _))); + // Optional invitation credential (VIC) to present when joining a community. + // Loaded eagerly so a malformed path / JSON fails fast with a clear message + // rather than silently dropping the invite mid-join. + let invitation_credential: Option = match matches + .get_one::("invitation") + { + Some(path) => { + let raw = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("failed to read invitation file `{path}`: {e}"))?; + let vic: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| anyhow::anyhow!("invitation file `{path}` is not valid JSON: {e}"))?; + Some(vic) + } + None => None, + }; + // Which configuration profile to use? let profile = if let Ok(env_profile) = env::var("OPENVTC_CONFIG_PROFILE") { // ENV Profile will override the CLI Argument @@ -240,7 +256,8 @@ async fn main() -> Result<()> { // Setup the initial state let (terminator, mut interrupt_rx) = create_termination(); - let (state, state_rx) = StateHandler::new(&profile, starting_mode); + let (mut state, state_rx) = StateHandler::new(&profile, starting_mode); + state.set_invitation_credential(invitation_credential); let (ui_manager, action_rx) = UiManager::new(); tokio::try_join!( diff --git a/openvtc/src/state_handler/join.rs b/openvtc/src/state_handler/join.rs index a62af5e..cde53f1 100644 --- a/openvtc/src/state_handler/join.rs +++ b/openvtc/src/state_handler/join.rs @@ -67,6 +67,12 @@ pub struct JoinState { /// The DID of the persona presented for this community, shown on the success /// page alongside the community DID. pub created_persona_did: Option, + /// Whether an invitation credential (VIC) was supplied at launch and will be + /// presented with this join. Mirrored from the top-level + /// [`State`](crate::state_handler::state::State) when the flow opens (it + /// survives `reset`, which is called once at open) so the entry page can show + /// the operator that their invitation will be used. + pub has_invitation: bool, } impl JoinState { diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index 83cc89a..4760730 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -84,6 +84,9 @@ impl StateHandler { ) -> Result { // Enter the flow on a fresh EnterDid page. state.join.reset(); + // Surface the launch-supplied invitation on the entry page (reset clears + // the transient join sub-state, so mirror the flag back in afterwards). + state.join.has_invitation = state.invitation_credential.is_some(); state.active_page = ActivePage::Join; let _ = self.state_tx.send(state.clone()); @@ -655,10 +658,18 @@ async fn run_join_sequence( return; } }; - let vp = serde_json::json!({ - "type": "VerifiablePresentation", - "holder": applicant_did, - }); + // Present the holder VP. When the applicant loaded an invitation credential + // (VIC) at startup (`--invitation`), it rides in the VP's + // `verifiableCredential` array; the VTC verifies it and auto-admits on a + // valid, trusted, unconsumed invitation (no manual approval). + if state.invitation_credential.is_some() { + state + .join + .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()); let request_id = match openvtc_core::join::submit_join_request( atm, &persona_profile, diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index 3b338fe..1128741 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -91,6 +91,9 @@ pub struct StateHandler { state_tx: tokio::sync::watch::Sender, profile: String, starting_mode: StartingMode, + /// Invitation credential (VIC) supplied at launch via `--invitation`, seeded + /// into the loop's initial [`State`] so the join flow can present it. + invitation_credential: Option, } pub(crate) enum SetupWizardExit { @@ -110,11 +113,18 @@ impl StateHandler { state_tx, profile: profile.to_string(), starting_mode, + invitation_credential: None, }, state_rx, ) } + /// Seed the invitation credential (VIC) to present when joining, parsed from + /// the `--invitation ` launch argument. No-op when `None`. + pub fn set_invitation_credential(&mut self, vic: Option) { + self.invitation_credential = vic; + } + pub async fn main_loop( mut self, mut terminator: Terminator, @@ -122,6 +132,10 @@ impl StateHandler { mut interrupt_rx: broadcast::Receiver, ) -> Result { let mut state = State::default(); + // Carry the launch-supplied invitation credential into the live state so + // the join flow can present it (it survives `JoinState::reset`, which + // only clears the transient join sub-state). + state.invitation_credential = self.invitation_credential.take(); let starting_mode = std::mem::replace(&mut self.starting_mode, StartingMode::NotSet); // The third element is the live admin VTA session handed back by diff --git a/openvtc/src/state_handler/state.rs b/openvtc/src/state_handler/state.rs index 04b9690..af3d1f2 100644 --- a/openvtc/src/state_handler/state.rs +++ b/openvtc/src/state_handler/state.rs @@ -51,6 +51,13 @@ pub struct State { /// Not gated behind the openpgp-card feature so the StateHandler's /// select loop can update it unconditionally regardless of build config. pub token_touch_pending: bool, + + /// A Verifiable Invitation Credential (VIC) the operator supplied at launch + /// via `--invitation `, to be presented when joining a community. The + /// VTC verifies it and auto-admits on a valid, trusted, unconsumed invite. + /// Runtime-only (never persisted); injected by `main` into the + /// [`StateHandler`](crate::state_handler::StateHandler)'s initial state. + pub invitation_credential: Option, } impl State { 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 e0fdebd..28d4ba0 100644 --- a/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs +++ b/openvtc/src/ui/pages/join_flow/vtc_enter_did.rs @@ -114,6 +114,15 @@ impl VtcEnterDid { )); } } + // When an invitation credential was supplied at launch, show that it + // will be presented — the community can then auto-admit without review. + if state.has_invitation { + lines.push(Line::styled( + "✓ Invitation credential loaded — it will be presented to the community.", + Style::new().fg(COLOR_SOFT_PURPLE).bold(), + )); + lines.push(Line::default()); + } lines.push(Line::from(vec![ Span::styled("[ESC]", Style::new().fg(COLOR_BORDER).bold()), Span::styled(" to cancel | ", Style::new().fg(COLOR_TEXT_DEFAULT)),