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
56 changes: 56 additions & 0 deletions openvtc-core/src/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
8 changes: 8 additions & 0 deletions openvtc/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}
Expand Down
19 changes: 18 additions & 1 deletion openvtc/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,22 @@ async fn main() -> Result<()> {
let unlock_code_arg = matches.get_one::<String>("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<serde_json::Value> = match matches
.get_one::<String>("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
Expand Down Expand Up @@ -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!(
Expand Down
6 changes: 6 additions & 0 deletions openvtc/src/state_handler/join.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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 {
Expand Down
19 changes: 15 additions & 4 deletions openvtc/src/state_handler/join_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ impl StateHandler {
) -> Result<JoinExit> {
// 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());

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions openvtc/src/state_handler/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ pub struct StateHandler {
state_tx: tokio::sync::watch::Sender<State>,
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<serde_json::Value>,
}

pub(crate) enum SetupWizardExit {
Expand All @@ -110,18 +113,29 @@ 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 <file>` launch argument. No-op when `None`.
pub fn set_invitation_credential(&mut self, vic: Option<serde_json::Value>) {
self.invitation_credential = vic;
}

pub async fn main_loop(
mut self,
mut terminator: Terminator,
mut action_rx: UnboundedReceiver<Action>,
mut interrupt_rx: broadcast::Receiver<Interrupted>,
) -> Result<Interrupted> {
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
Expand Down
7 changes: 7 additions & 0 deletions openvtc/src/state_handler/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>`, 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<serde_json::Value>,
}

impl State {
Expand Down
9 changes: 9 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 @@ -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)),
Expand Down
Loading