diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f071237..f928b664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Inbound dispatch and the join protocol live in `openvtc-core`** + ([#530](https://github.com/OpenVTC/openvtc/issues/530)). `process_inbound_message` and + what it reports (`InboundEffects`, the off-loop `VerifyJob`s) moved from the CLI to + `openvtc_core::inbound`; relationship-DID key creation to + `openvtc_core::relationships::keys`; the join sequence's protocol steps (manifest over + HTTP, context choice, submit transport, signed submit, pending record, persona + rollback) to `openvtc_core::join::sequence`, and the join status poll to + `openvtc_core::join::status_poll`. The TUI and `openvtc community join` now send a join + through the same code. `openvtc community join` prints a line when the submit goes + over a different transport than the community prefers, as the TUI already did. +- **One registry decides which inbound message types are accepted and who handles them** + ([#531](https://github.com/OpenVTC/openvtc/issues/531)). The transport's accept gate + and the dispatcher are both derived from `openvtc_core::inbound::registry`, so a + handler cannot exist for a type the gate drops. The gate no longer admits whole + namespaces no handler claims (any `spec/vtc/*`, the legacy `openvtc/vtc/*` authority, + any `linuxfoundation.org/openvtc/*`): those messages were already ignored by the + dispatcher as unknown, and are now dropped at the gate instead, still logged at `warn`. +- **Remote failures carry a typed class** ([#536](https://github.com/OpenVTC/openvtc/issues/536)). + A VTA call that fails now reports whether the VTA was unreachable, timed out, refused + our credentials, answered in a shape this client cannot read, did not know the thing, + refused with a reason, or failed on its side — in the message (e.g. `VTA timed out: + …` where it used to say `VTA Error: …`) and as data. Startup's "couldn't reach the + VTA" guidance follows the class. +- **Library API** (`openvtc-core`, additive unless noted): `inbound` (`process_inbound_message`, + `apply_inline`, `InboundEffects`, `ThreadedReply`, `VerifyJob`, `PreVerified`, + `Arrival`, `Deferred`, `registry::{ROUTES, Family, TypeMatcher, Route, classify}`); + `relationships::keys`; `join::sequence`; `join::status_poll`; `vta_task::{retry, + retryable, MAX_ATTEMPTS}`; `capabilities::CAPABILITY_TYPE_PREFIX`; + `OpenVTCError::Remote` with `RemoteService`, `RemoteKind`, `ErrorClass`, + `OpenVTCError::{vta, remote, class, remote_kind}` and `errors::class_of`; + `DiscoverError::kind` and `MessagingError::class`; a `test-support` feature (test-only + hooks). **Removed:** `didcomm::OPENVTC_CATCH_ALL_PATTERN` (the gate is the registry + now; `didcomm::routes_inbound_type` still answers whether a type is admitted). Errors + that were `OpenVTCError::Vta(String)` from VTA calls are now `OpenVTCError::Remote`. + - **Faster startup, sends and `health`** ([#557](https://github.com/OpenVTC/openvtc/issues/557), [#559](https://github.com/OpenVTC/openvtc/issues/559), [#560](https://github.com/OpenVTC/openvtc/issues/560)). A persona's VTA-held keys are diff --git a/Cargo.lock b/Cargo.lock index 2a07e48a..980d3158 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6357,7 +6357,6 @@ dependencies = [ name = "openvtc" version = "0.8.0" dependencies = [ - "affinidi-data-integrity", "affinidi-tdk", "anyhow", "arboard", @@ -6443,7 +6442,6 @@ dependencies = [ "openssl-sys", "pgp", "rand 0.8.8", - "regex", "reqwest", "secrecy", "serde", diff --git a/Cargo.toml b/Cargo.toml index 564b4004..f368af79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,6 @@ qrcode = { version = "0.14", default-features = false } # rand_core 0.6 traits; our `OsRng` is handed to it directly. Moves with `pgp`. rand = "0.8" ratatui = "0.30" -regex = "1.12" secrecy = { version = "0.10", features = ["serde"] } serde = "1.0" serde_json = "1.0" diff --git a/docs/design/tui-architecture.md b/docs/design/tui-architecture.md index ac054c66..2b09ce65 100644 --- a/docs/design/tui-architecture.md +++ b/docs/design/tui-architecture.md @@ -72,7 +72,6 @@ the real work is decomposed into focused modules (declared at `mod.rs:50`): |--------|------| | `actions/` | The `Action` enum + domain sub-enums (`InboxAction`, `RelationshipAction`, `CredentialAction`, `SettingsAction`). | | `state.rs` | The `State` struct + sub-states (`MainPageState`, `ConnectionState`, `MediatorStatus`, `ActivePage`, `LoadingProgress`). | -| `message_dispatch.rs` | Inbound DIDComm orchestration: `process_inbound_message` (async I/O only). | | `background_dispatch.rs` | Off-loop network dispatch plumbing (see §4). | | `save_coalesce.rs` | Coalesced, off-runtime `Config` persistence (see §4). | | `agent_name_refresh.rs` | Off-loop batch resolution of agent names (DID→name), applied via background-dispatch (see §4). | @@ -87,7 +86,10 @@ the real work is decomposed into focused modules (declared at `mod.rs:50`): credential-issue), VRC vetting/proof verification, and the replay guard (`SeenMessages`) were moved to [`openvtc-core/src/messaging.rs`](../../openvtc-core/src/messaging.rs) so they are -testable without the TUI crate; `message_dispatch.rs` imports and calls into them. +testable without the TUI crate. Inbound dispatch itself (`process_inbound_message`, +`InboundEffects`, the off-loop `VerifyJob`s) lives in +[`openvtc-core/src/inbound/`](../../openvtc-core/src/inbound/mod.rs); the runtime +loop calls it and applies the returned `InboundEffects` to `State`. The `MessageType` protocol enum lives in [`openvtc-core/src/lib.rs:171`](../../openvtc-core/src/lib.rs). @@ -164,10 +166,15 @@ the verified `(did, name)` results into `Config::agent_names` on the loop thread 2. Put the **pure** handling logic (validation, body parsing, state derivation) in [`openvtc-core/src/messaging.rs`](../../openvtc-core/src/messaging.rs) so it is unit-testable without the TUI. -3. Add a `MessageType::…` arm to `process_inbound_message` in - [`state_handler/message_dispatch.rs`](../../openvtc/src/state_handler/message_dispatch.rs), - calling the core handler and returning `Ok(true)` if it mutated `Config` (the - loop then `mark_dirty`s and re-syncs the UI). +3. Add a `Family` variant and its route to the inbound registry + ([`openvtc-core/src/inbound/registry.rs`](../../openvtc-core/src/inbound/registry.rs)). + The transport's accept gate and the dispatcher both read that one table, so + the type is admitted exactly when it is routed. The dispatcher's + `match family` is exhaustive: add the family's handler in + [`openvtc-core/src/inbound/mod.rs`](../../openvtc-core/src/inbound/mod.rs), + calling the core logic and returning `Ok(true)` if it mutated `Config` (the + loop then `mark_dirty`s and re-syncs the UI). Add the type to the census in + `openvtc-core/tests/inbound_gate_census.rs`. 4. If sending the message is user-initiated, add the corresponding `Action` + key (recipes above) and build the outbound message with `openvtc_core::messaging::build_didcomm_message`. diff --git a/docs/design/vetting-process.md b/docs/design/vetting-process.md index 7539e54d..d36a6aa8 100644 --- a/docs/design/vetting-process.md +++ b/docs/design/vetting-process.md @@ -497,7 +497,7 @@ Throttling: **Where the gate lives.** - **V0:** tickets are stored in `ProtectedConfig` (per community, like - `tasks`). The gate runs in `openvtc/src/state_handler/message_dispatch.rs` before a + `tasks`). The gate runs in `openvtc-core/src/inbound/mod.rs` before a `TaskType` is created. - **V1:** tickets move to the vetter's VTA (`vetting/tickets/*`). The gate runs as `vta-policy` Rego on the inbound task URI, so rejected requests diff --git a/openvtc-core/Cargo.toml b/openvtc-core/Cargo.toml index e7b0746c..7edbc344 100644 --- a/openvtc-core/Cargo.toml +++ b/openvtc-core/Cargo.toml @@ -27,6 +27,10 @@ openpgp-card = [ # (issue #124). Off by default; nightly fuzz builds opt in. Pulls chrono's # own `arbitrary` feature for the `LogMessage` timestamp. arbitrary = ["dep:arbitrary", "chrono/arbitrary"] +# Test-only hooks other crates' tests need (e.g. inbound `VerifyJob`s that +# hang or panic, for the CLI's verify-queue tests). Never enabled by a +# release build; the CLI turns it on from `[dev-dependencies]` only. +test-support = [] [dependencies] # The commit signer the Repos panel sets up for a persona (`git_signing`): its @@ -63,9 +67,6 @@ affinidi-messaging-delivery = { workspace = true } affinidi-messaging-core = { workspace = true } affinidi-messaging-sdk = { workspace = true } futures-util = { workspace = true } -# The inbound dispatcher compiles `OPENVTC_CATCH_ALL_PATTERN` to decide which -# message types reach the state handler; the delivery layer has no type routing. -regex = { workspace = true } agent-names.workspace = true base64.workspace = true bip39.workspace = true @@ -133,8 +134,9 @@ ed25519-dalek-bip32 = { workspace = true } secrecy = { workspace = true } reqwest = { workspace = true } serde_json = { workspace = true } -# `net`: the loopback listener in the `health` tests. -tokio = { workspace = true, features = ["net"] } +# `net`: the loopback listener in the `health` tests. `test-util`: the paused +# clock for the VTA retry back-off tests. +tokio = { workspace = true, features = ["net", "test-util"] } # Mock HTTP server for the agent-name resolve→verify e2e: it serves the # `/@name` → 302 → DID redirect the `HttpRedirectResolver` follows, so the whole # chain is exercised without a live host. diff --git a/openvtc-core/src/capabilities.rs b/openvtc-core/src/capabilities.rs index 2548d312..197d3451 100644 --- a/openvtc-core/src/capabilities.rs +++ b/openvtc-core/src/capabilities.rs @@ -23,6 +23,10 @@ pub use trust_tasks_capability_client::{ correlation_thread, parse_capability_reply, parse_envelope_document, parse_envelope_reply, }; +/// The type-URI prefix every `governance/capability/*` document shares: the +/// answers this client routes to the capability view, in every version. +pub const CAPABILITY_TYPE_PREFIX: &str = "https://trusttasks.org/spec/governance/capability/"; + /// A request document as the builders here (and [`crate::git_ns::build`]) /// return it — named so a caller can hold one between building and sending /// without depending on `trust-tasks-rs` itself. diff --git a/openvtc-core/src/community_rooms.rs b/openvtc-core/src/community_rooms.rs index c50e8d05..02e75185 100644 --- a/openvtc-core/src/community_rooms.rs +++ b/openvtc-core/src/community_rooms.rs @@ -59,7 +59,7 @@ pub use offers::OfferV2 as Offer; pub use offers::Renewal; /// Every type this module sends or reads starts with this. -const TYPE_PREFIX: &str = "https://trusttasks.org/spec/vtc/rooms/"; +pub(crate) const TYPE_PREFIX: &str = "https://trusttasks.org/spec/vtc/rooms/"; /// Longest text from the community kept for display. const MAX_PEER_TEXT: usize = 512; diff --git a/openvtc-core/src/config/community_context.rs b/openvtc-core/src/config/community_context.rs index c54fa06c..035970fb 100644 --- a/openvtc-core/src/config/community_context.rs +++ b/openvtc-core/src/config/community_context.rs @@ -298,9 +298,10 @@ pub async fn ensure_context( Ok(_) => continue, Err(VtaError::NotFound(_)) => {} Err(e) => { - return Err(OpenVTCError::Vta(format!( - "could not look up context {path}: {e}" - ))); + return Err(OpenVTCError::vta( + format!("could not look up context {path}"), + &e, + )); } } let Some((parent, slug)) = parse_sub_context_id(path) else { @@ -318,9 +319,10 @@ pub async fn ensure_context( Ok(_) => created = true, Err(VtaError::Conflict(_)) => {} Err(e) => { - return Err(OpenVTCError::Vta(format!( - "could not create context {path}: {e}" - ))); + return Err(OpenVTCError::vta( + format!("could not create context {path}"), + &e, + )); } } } @@ -750,25 +752,28 @@ pub(crate) fn vta_failure(action: &str, e: VtaError) -> OpenVTCError { // Not "could not reach": the request was sent, and it is the reply that // is missing — which, repeated, is a session whose inbox is not being // collected, with its own remedy (R6.4, `vta_receive_leg`). - ref e if crate::vta_receive_leg::is_reply_timeout(e) => OpenVTCError::Vta(format!( - "the request to {action} was sent, but no reply from your VTA reached this app \ - in time: {e}" - )), + ref e if crate::vta_receive_leg::is_reply_timeout(e) => OpenVTCError::vta( + format!( + "the request to {action} was sent, but no reply from your VTA reached this app \ + in time" + ), + e, + ), VtaError::Network(_) | VtaError::DidcommTransport(_) | VtaError::TspTransport(_) => { - OpenVTCError::Vta(format!("could not reach your VTA to {action}: {e}")) + OpenVTCError::vta(format!("could not reach your VTA to {action}"), &e) } VtaError::Server { .. } => { - OpenVTCError::Vta(format!("your VTA failed while trying to {action}: {e}")) + OpenVTCError::vta(format!("your VTA failed while trying to {action}"), &e) } - VtaError::Auth(_) => OpenVTCError::Auth(format!( - "your VTA did not accept this session to {action}: {e}" - )), - VtaError::Forbidden(_) => OpenVTCError::Auth(format!( - "your VTA does not allow this session to {action}: {e}" - )), - other => OpenVTCError::Vta(format!( - "your VTA rejected the request to {action}: {other}" - )), + VtaError::Auth(_) => OpenVTCError::vta( + format!("your VTA did not accept this session to {action}"), + &e, + ), + VtaError::Forbidden(_) => OpenVTCError::vta( + format!("your VTA does not allow this session to {action}"), + &e, + ), + other => OpenVTCError::vta(format!("your VTA rejected the request to {action}"), &other), } } @@ -1353,14 +1358,19 @@ mod tests { /// Unreachable, not accepted, and refused read differently. #[test] fn a_vta_failure_says_which_kind_it_was() { - let unreachable = vta_failure("x", VtaError::DidcommTransport("timeout".into())); - assert!(matches!(unreachable, OpenVTCError::Vta(ref m) if m.contains("could not reach"))); + use crate::errors::ErrorClass; + let unreachable = vta_failure("x", VtaError::DidcommTransport("refused".into())); + assert_eq!(unreachable.class(), ErrorClass::Unreachable); + assert!(unreachable.to_string().contains("could not reach")); let auth = vta_failure("x", VtaError::Auth("expired".into())); - assert!(matches!(auth, OpenVTCError::Auth(ref m) if m.contains("did not accept"))); + assert_eq!(auth.class(), ErrorClass::AuthRejected); + assert!(auth.to_string().contains("did not accept")); let forbidden = vta_failure("x", VtaError::Forbidden("no".into())); - assert!(matches!(forbidden, OpenVTCError::Auth(ref m) if m.contains("does not allow"))); + assert_eq!(forbidden.class(), ErrorClass::AuthRejected); + assert!(forbidden.to_string().contains("does not allow")); let rejected = vta_failure("x", VtaError::Validation("bad".into())); - assert!(matches!(rejected, OpenVTCError::Vta(ref m) if m.contains("rejected"))); + assert_eq!(rejected.class(), ErrorClass::Refused); + assert!(rejected.to_string().contains("rejected")); // A reply timeout is none of the three: the request was sent. for timeout in [ VtaError::TspTransport( @@ -1368,14 +1378,12 @@ mod tests { ), VtaError::DidcommTransport("timeout waiting for DIDComm response".into()), ] { - match vta_failure("x", timeout) { - OpenVTCError::Vta(m) => { - assert!(m.contains("was sent, but no reply"), "{m}"); - assert!(!m.contains("could not reach"), "{m}"); - assert!(!m.contains("rejected"), "{m}"); - } - other => panic!("expected Vta, got {other:?}"), - } + let e = vta_failure("x", timeout); + assert_eq!(e.class(), ErrorClass::Timeout); + let m = e.to_string(); + assert!(m.contains("was sent, but no reply"), "{m}"); + assert!(!m.contains("could not reach"), "{m}"); + assert!(!m.contains("rejected"), "{m}"); } } diff --git a/openvtc-core/src/config/mod.rs b/openvtc-core/src/config/mod.rs index 020a4c80..008c42f5 100644 --- a/openvtc-core/src/config/mod.rs +++ b/openvtc-core/src/config/mod.rs @@ -1581,15 +1581,15 @@ async fn enable_tsp_with_resolver( /// callers can keep distinguishing retryable transport/auth failures from /// genuine config corruption (R18). An empty `vta_url` on the REST path comes /// back as [`vta_sdk::error::VtaError::Validation`] — that is a bad on-disk -/// config, so it maps to [`OpenVTCError::Config`]; auth rejection maps to -/// [`OpenVTCError::Auth`]; everything else (network, DIDComm session open) is a -/// live-VTA reachability problem, [`OpenVTCError::Vta`]. +/// config, so it maps to [`OpenVTCError::Config`]; everything else is a live-VTA +/// failure, [`OpenVTCError::Remote`] classified from the SDK error (auth +/// rejected, unreachable, timed out, …). fn map_connect_error(e: vta_sdk::error::VtaError) -> OpenVTCError { use vta_sdk::error::VtaError; match e { VtaError::Validation(msg) => OpenVTCError::Config(msg), - VtaError::Auth(msg) => OpenVTCError::Auth(format!("VTA authentication failed: {msg}")), - other => OpenVTCError::Vta(format!("VTA connection failed: {other}")), + other @ VtaError::Auth(_) => OpenVTCError::vta("VTA authentication failed", &other), + other => OpenVTCError::vta("VTA connection failed", &other), } } diff --git a/openvtc-core/src/devices.rs b/openvtc-core/src/devices.rs index 709f80e5..60d90588 100644 --- a/openvtc-core/src/devices.rs +++ b/openvtc-core/src/devices.rs @@ -229,15 +229,19 @@ pub async fn register( Ok(response) => response, Err(e) if is_already_registered(&e) => return Ok(Registration::AlreadyRegistered), Err(e) => { - return Err(OpenVTCError::Vta(format!( - "device registration failed: {e}" - ))); + return Err(OpenVTCError::vta("device registration failed", &e)); } }; parse_binding(&response) .map(Registration::Claimed) - .ok_or_else(|| OpenVTCError::Vta("device registration returned no binding".to_string())) + .ok_or_else(|| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + "device registration returned no binding", + ) + }) } /// Whether a `device/register` failure is "this DID is already registered". @@ -291,7 +295,7 @@ pub async fn heartbeat( .await, ) .map(|_| ()) - .map_err(|e| OpenVTCError::Vta(format!("device heartbeat failed: {e}"))) + .map_err(|e| OpenVTCError::vta("device heartbeat failed", &e)) } /// Whether `row` — this install's own binding — is showing a stale name. @@ -320,12 +324,18 @@ pub async fn list( ) -> Result, OpenVTCError> { let response = receive .observe(client.device_list(serde_json::json!({})).await) - .map_err(|e| OpenVTCError::Vta(format!("device list failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("device list failed", &e))?; let devices = response .get("devices") .and_then(|d| d.as_array()) - .ok_or_else(|| OpenVTCError::Vta("device list returned no `devices` array".to_string()))?; + .ok_or_else(|| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + "device list returned no `devices` array", + ) + })?; // One unparseable row must not lose the rest of the listing: this is a // diagnostic surface, and a partial answer beats none. diff --git a/openvtc-core/src/diagnostics.rs b/openvtc-core/src/diagnostics.rs index b8c1fc09..8fd1974f 100644 --- a/openvtc-core/src/diagnostics.rs +++ b/openvtc-core/src/diagnostics.rs @@ -26,7 +26,7 @@ use crate::{ config::public_config::profile_dir, - errors::{OpenVTCError, SecureStoreFault}, + errors::{ErrorClass, OpenVTCError, SecureStoreFault}, secure_store::{self, EntryStatus}, }; @@ -384,6 +384,44 @@ pub fn diagnose(err: &OpenVTCError, ctx: &DiagnosisContext) -> Diagnosis { ], }, + // A typed remote failure: credentials refused reads as the handshake + // rejection below; every other kind as a VTA/mediator fault, with the + // remedy for its own kind first (R6.4). + OpenVTCError::Remote { kind, detail, .. } if err.class() == ErrorClass::AuthRejected => { + Diagnosis { + headline: "The VTA rejected this identity.".to_string(), + error: err.to_string(), + cause: format!( + "The VTA was reached, so the network is fine; it declined this \ + session's credentials ({kind}). ({detail})" + ), + context, + checks: vec![format!("openvtc health --profile {profile}")], + remedies: vec![ + "Confirm this profile's DID is still registered with that VTA.".to_string(), + "Check the VTA's own logs for the rejection reason.".to_string(), + "Do NOT reset the profile; the keys are intact.".to_string(), + ], + } + } + OpenVTCError::Remote { kind, detail, .. } => Diagnosis { + headline: format!("A remote service {kind}."), + error: err.to_string(), + cause: format!( + "Your local configuration and keys loaded fine; the failure is between this \ + machine and a remote service. ({detail})" + ), + context, + checks: vec![ + format!("openvtc health --profile {profile}"), + "check DNS/proxy/VPN reachability to the VTA host".to_string(), + ], + remedies: vec![ + err.class().remedy().to_string(), + "Run `openvtc health` for a per-DID resolution and transport map.".to_string(), + ], + }, + // The genuinely network-shaped failures — the only ones the old fixed // hint was ever right about. OpenVTCError::Vta(reason) => Diagnosis { diff --git a/openvtc-core/src/didcomm.rs b/openvtc-core/src/didcomm.rs index f5a938b5..835de071 100644 --- a/openvtc-core/src/didcomm.rs +++ b/openvtc-core/src/didcomm.rs @@ -86,6 +86,21 @@ pub enum MessagingError { Send(String), } +impl MessagingError { + /// The class of this failure (R6.4): a listener that would not come up or + /// a send the delivery layer refused is the mediator unreachable; a send + /// to a listener that is not installed, or a message that would not pack, + /// is local. + #[must_use] + pub fn class(&self) -> crate::errors::ErrorClass { + use crate::errors::ErrorClass; + match self { + MessagingError::Listener { .. } | MessagingError::Send(_) => ErrorClass::Unreachable, + MessagingError::UnknownListener(_) | MessagingError::Pack { .. } => ErrorClass::Local, + } + } +} + /// How a listener is described, independently of the framework that runs it. /// /// The delivery-layer swap replaces `ListenerConfig` (a @@ -1667,19 +1682,6 @@ pub fn bound_sender( } } -/// The routing gate, compiled once and shared by the live dispatcher and the -/// stored-message pickup so the two admit **exactly** the same set. -/// -/// It used to be compiled inside `dispatch_inbound`, where a compile failure -/// silently killed inbound dispatch for the process. The pattern is a `const` -/// covered by `catch_all_tests`, so a failure is a build-time bug; treating it -/// as one here is what lets both call sites share a single compiled regex -/// without either carrying an unreachable error path. -static CATCH_ALL: std::sync::LazyLock = std::sync::LazyLock::new(|| { - regex::Regex::new(&format!("^(?:{OPENVTC_CATCH_ALL_PATTERN})$")) - .expect("OPENVTC_CATCH_ALL_PATTERN is a constant, and `catch_all_tests` compiles it") -}); - /// Classify one already-decoded inbound message into the events the state /// handler consumes. Returns **zero or more**: an unroutable type yields none, a /// trust-pong yields two (the log event and the message itself). @@ -1722,7 +1724,7 @@ fn classify_inbound( }, ]; } - if CATCH_ALL.is_match(&message.typ) { + if crate::inbound::registry::classify(&message.typ).is_some() { tracing::info!( listener = %crate::display::truncate_did(&listener_id, 32), msg_type = %message.typ, @@ -1742,7 +1744,10 @@ fn classify_inbound( // and stay at `debug!`; anything over TSP, or any Trust Task, is a message a // peer meant for us — dropping one of those silently is how an answer the // peer sent turns into a timeout here with nothing in the log (R6.4). - if transport == MessagingTransport::Tsp || is_trust_task_type(&message.typ) { + if transport == MessagingTransport::Tsp + || is_trust_task_type(&message.typ) + || is_openvtc_protocol_type(&message.typ) + { tracing::warn!( listener = %crate::display::truncate_did(&listener_id, 32), msg_type = %message.typ, @@ -1763,6 +1768,14 @@ fn is_trust_task_type(typ: &str) -> bool { typ.starts_with("https://trusttasks.org/") } +/// Whether `typ` is in one of OpenVTC's own protocol namespaces (relationship +/// and VRC messages). One this client does not handle is a peer speaking a +/// version or verb we lack — worth a line at `warn!`, as for a Trust Task. +fn is_openvtc_protocol_type(typ: &str) -> bool { + typ.starts_with("https://linuxfoundation.org/openvtc/") + || typ.starts_with("https://firstperson.network/") +} + /// Normalise an inbound TSP frame into the [`Message`] shape the dispatcher and /// the whole `crate::messaging` handler set already speak. /// @@ -1941,87 +1954,28 @@ pub fn open_didcomm_envelope(message: &Message) -> Option { Some(opened) } -/// Catch-all pattern for OpenVTC protocol messages + VTC Trust-Task -/// replies (e.g. `join-requests/submit-receipt`). The state handler -/// dispatches by type and ignores any it doesn't handle. -/// -/// **Both VTC prefixes are accepted.** The VTC's Trust Tasks are moving -/// from the non-conformant `trusttasks.org/openvtc/vtc/…` authority to -/// the canonical registry at `trusttasks.org/spec/vtc/…`. This pattern -/// decides whether a message reaches the handler *at all*, so it must -/// accept the new prefix **before** any VTC starts emitting it — -/// otherwise migrated traffic is dropped here, silently, before dispatch -/// ever sees it. Accepting both also lets a migrated and an unmigrated -/// VTC be talked to during the rollout; the `openvtc/vtc/` arm can be -/// retired once no supported VTC emits it. /// Would an inbound message of this `type` reach the handler? /// -/// The gate itself, exposed so the dispatcher that routes these types can be -/// checked against it. Before this, the only way to ask was to recompile -/// [`OPENVTC_CATCH_ALL_PATTERN`] and ask a copy — which is the same shape of -/// mistake the pattern keeps producing: two descriptions of one fact, agreeing -/// until one of them changes. -/// -/// # Why this matters more than it looks -/// -/// A `type` this returns `false` for is dropped at `classify_inbound` with a -/// `debug!` and no reply. There is no error, no problem-report, and nothing on -/// the sender's side but a wait that never ends. Adding a handler and forgetting -/// the prefix is therefore a *total, silent* failure of that verb — which has -/// already happened here: `capabilities::send_capability_document` sent the -/// binding envelope, `process_inbound_message` had a branch for it, and every -/// reply was dropped in between. +/// The gate itself, derived from the inbound registry +/// ([`crate::inbound::registry::ROUTES`]): a type is admitted exactly when a +/// handler claims it, and the dispatcher routes on the same classification. +/// The two used to be separate lists — a hand-written regex here and a chain +/// of branches in the dispatcher — and a branch added without its prefix here +/// was a verb that failed totally and silently: a `type` refused here is +/// dropped at `classify_inbound` with no reply, and the sender waits out its +/// timeout. That happened (`capabilities::send_capability_document` sent the +/// binding envelope, the dispatcher had a branch for the reply, and every +/// reply was dropped in between). #[must_use] pub fn routes_inbound_type(type_uri: &str) -> bool { // Mirrors [`classify_inbound`]'s admission order exactly. The two - // trust-ping types return before the catch-all is consulted, so a predicate - // that asked only the pattern would report them as dropped — which is the - // wrong answer, and the kind of near-miss that makes a census worse than - // useless: it would send someone to widen a regex that was never the - // problem. - type_uri == TRUST_PING_TYPE || type_uri == TRUST_PONG_TYPE || CATCH_ALL.is_match(type_uri) -} - -pub const OPENVTC_CATCH_ALL_PATTERN: &str = concat!( - r"https://linuxfoundation\.org/openvtc/.*", - r"|https://firstperson\.network/.*", - r"|https://trusttasks\.org/openvtc/vtc/.*", - r"|https://trusttasks\.org/spec/vtc/.*", - r"|https://trusttasks\.org/spec/credential-exchange/.*", - r"|https://trusttasks\.org/spec/vetting/.*", - r"|https://trusttasks\.org/spec/trust-task-error/.*", - // Capability replies typed as the response document, which is how a VTC - // answers today (the envelope arm below is how it will once it follows the - // binding's §5 fully). `message_dispatch` reads both. - r"|https://trusttasks\.org/spec/governance/capability/.*", - // Git namespace replies (`git-ns/*`) for the Repos panel. A DIDComm reply - // in the binding envelope passed the gate under the `binding/` arm below, - // which hid the gap: TSP opens the envelope *before* the gate - // (`tsp_document_to_message`), so a TSP-joined member's `git-ns/view` - // answer arrived typed as the task, matched nothing here, and was dropped - // at `debug!` — the view waited out its timeout on an answer the VTC sent. - r"|https://trusttasks\.org/spec/git-ns/.*", - // A witness's answers to the documents a witnessing carries (`witness/session/*#response`, - // `openvtc_core::witness`). Over DIDComm they pass in the binding envelope below; TSP opens - // the envelope before the gate, so without this arm a witness answering over TSP would be - // dropped here and the witnessing would wait out its timeout. - r"|https://trusttasks\.org/spec/witness/.*", - // Notifications addressed to a persona (`notifications/notify`, `notifications/relay`), - // which this client hands to the member's VTA (VTI-NTF-054). Over DIDComm they pass in the - // binding envelope; TSP opens the envelope before the gate, so without this arm a - // notification delivered over TSP would be dropped here and never reach the VTA. - r"|https://trusttasks\.org/spec/notifications/.*", - // The **binding envelopes**, whose type says "a Trust Task is inside" and - // names no task. A peer built on `trust-tasks-didcomm` types every message - // this way, and so does this crate's own `capabilities:: - // send_capability_document` — which is how the hole showed up: we sent the - // envelope type, the VTC replied in kind, and the reply was dropped *here*, - // before `process_inbound_message` could reach the handler it already has - // for it. A gate keyed on task URIs cannot see a carriage that deliberately - // does not name one. - r"|https://trusttasks\.org/binding/.*", - r"|https://didcomm\.org/report-problem/.*", -); + // trust-ping types are taken before the registry is consulted (a ping is + // answered by the state handler, not dispatched), and both are in the + // registry too, so this is the registry's answer. + type_uri == TRUST_PING_TYPE + || type_uri == TRUST_PONG_TYPE + || crate::inbound::registry::classify(type_uri).is_some() +} /// Extract secrets for a DID from the TDK's secrets resolver. /// @@ -3684,122 +3638,6 @@ mod tsp_inbound_tests { } } -#[cfg(test)] -mod catch_all_tests { - use super::OPENVTC_CATCH_ALL_PATTERN; - use regex::Regex; - - fn matches(uri: &str) -> bool { - // `route_regex` anchors the whole type string, so mirror that - // here rather than testing a substring match that would pass - // for URIs the router would actually reject. - Regex::new(&format!("^(?:{OPENVTC_CATCH_ALL_PATTERN})$")) - .expect("catch-all pattern compiles") - .is_match(uri) - } - - /// The migration target. Without this arm, a migrated VTC's replies - /// never reach the handler — dropped by the router, before dispatch. - #[test] - fn canonical_vtc_trust_tasks_are_routed() { - for uri in [ - "https://trusttasks.org/spec/vtc/join-requests/submit/0.1", - "https://trusttasks.org/spec/vtc/join-requests/submit/0.1#response", - "https://trusttasks.org/spec/vtc/join-requests/status/0.1#response", - "https://trusttasks.org/spec/vtc/members/self-remove/0.1", - ] { - assert!(matches(uri), "{uri} must reach the OpenVTC handler"); - } - } - - /// Peer vetting tasks travel member to member, not through a community, - /// so they live under their own prefix; and a refusal of any Trust Task - /// arrives as a `trust-task-error`, which a vetter uses to refuse a request. - /// Without these arms both are dropped before dispatch. - #[test] - fn vetting_tasks_and_trust_task_errors_are_routed() { - for uri in [ - "https://trusttasks.org/spec/vetting/request/0.1", - "https://trusttasks.org/spec/vetting/request/0.1#response", - "https://trusttasks.org/spec/vetting/session/0.1", - "https://trusttasks.org/spec/vetting/session/0.1#response", - "https://trusttasks.org/spec/vetting/decline/0.1", - "https://trusttasks.org/spec/vtc/vetting/revoke-statement/0.1#response", - "https://trusttasks.org/spec/vtc/join-requests/manifest/0.2#response", - "https://trusttasks.org/spec/trust-task-error/0.5", - ] { - assert!(matches(uri), "{uri} must reach the OpenVTC handler"); - } - } - - /// The pre-migration prefix keeps working, so an unmigrated VTC is - /// still reachable during the rollout. - #[test] - fn legacy_vtc_trust_tasks_still_route() { - for uri in [ - "https://trusttasks.org/openvtc/vtc/spec/join-requests/submit/1.0", - "https://trusttasks.org/openvtc/vtc/members/self-remove/1.0", - ] { - assert!(matches(uri), "{uri} must still reach the handler"); - } - } - - #[test] - fn the_other_arms_are_intact() { - for uri in [ - "https://linuxfoundation.org/openvtc/anything", - "https://firstperson.network/protocols/x", - "https://trusttasks.org/spec/credential-exchange/offer/0.1", - "https://didcomm.org/report-problem/2.0/problem-report", - ] { - assert!(matches(uri), "{uri} must reach the handler"); - } - } - - /// The binding envelopes reach the handler. - /// - /// This is the one the gate was missing, and missing it was silent in both - /// directions: `capabilities::send_capability_document` sends with - /// `TRUST_TASK_ENVELOPE_TYPE`, `message_dispatch::process_inbound_message` - /// has a branch for it — and every such reply was dropped in between, - /// because a gate keyed on task URIs cannot see a carriage that names no - /// task. A handler that exists and is unreachable looks exactly like a peer - /// that never answered. - #[test] - fn the_binding_envelopes_are_routed() { - for uri in [ - "https://trusttasks.org/binding/didcomm/0.1/envelope", - "https://trusttasks.org/binding/tsp/0.1/envelope", - ] { - assert!(matches(uri), "{uri} must reach the OpenVTC handler"); - } - } - - /// And specifically the constant this crate sends with, read from the crate - /// that defines it rather than retyped here — so the gate cannot drift from - /// what `send_capability_document` puts on the wire. - #[test] - fn the_envelope_type_this_crate_sends_is_routed() { - assert!( - matches(crate::capabilities::TRUST_TASK_ENVELOPE_TYPE), - "the type we send must be one we accept back" - ); - } - - /// The pattern is a routing gate, not a catch-everything: an - /// unrelated canonical task must not be swept in. - #[test] - fn unrelated_types_are_not_routed() { - for uri in [ - "https://trusttasks.org/spec/acl/list/0.1", - "https://trusttasks.org/spec/policy/upsert/0.2", - "https://example.com/whatever", - ] { - assert!(!matches(uri), "{uri} must NOT be routed here"); - } - } -} - #[cfg(test)] mod authenticated_sender_tests { use super::authenticated_sender; diff --git a/openvtc-core/src/elections.rs b/openvtc-core/src/elections.rs index aca912bb..9a10c70c 100644 --- a/openvtc-core/src/elections.rs +++ b/openvtc-core/src/elections.rs @@ -83,7 +83,7 @@ pub use show::{ }; /// Every community task this module sends or reads starts with this. -const TYPE_PREFIX: &str = "https://trusttasks.org/spec/vtc/elections/"; +pub(crate) const TYPE_PREFIX: &str = "https://trusttasks.org/spec/vtc/elections/"; /// The prefix every refusal document's type carries. const ERROR_TYPE_PREFIX: &str = "https://trusttasks.org/spec/trust-task-error/"; diff --git a/openvtc-core/src/errors.rs b/openvtc-core/src/errors.rs index a2de84f7..148ffc91 100644 --- a/openvtc-core/src/errors.rs +++ b/openvtc-core/src/errors.rs @@ -159,6 +159,300 @@ pub enum OpenVTCError { /// A process lock-file operation (create, read, or remove) failed. #[error("Lock file error: {0}")] LockFile(String), + + /// A remote service failed, classified by what an operator does about it + /// (dev-guide R6.4): network unreachable, timed out, credentials refused, + /// a contract mismatch, not found, refused with a reason, or failed on its + /// side. Prefer this over [`Self::Vta`] / [`Self::Config`] for any failure + /// that came back from — or never reached — another service; build it from + /// the SDK error with [`Self::vta`] or [`Self::remote`]. + #[error("{service} {kind}: {detail}")] + Remote { + /// Which service. + service: RemoteService, + /// What kind of failure, for the remedy. + kind: RemoteKind, + /// What was being done, and the service's own words. + detail: String, + }, +} + +/// The remote service a [`OpenVTCError::Remote`] came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum RemoteService { + /// The account's own VTA. + Vta, + /// A DIDComm / TSP mediator. + Mediator, + /// A community (VTC) or another member, over messaging. + Peer, + /// An HTTP endpoint another party publishes (a manifest, a status list, a + /// DID document host). + Http, +} + +impl std::fmt::Display for RemoteService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + RemoteService::Vta => "VTA", + RemoteService::Mediator => "mediator", + RemoteService::Peer => "peer", + RemoteService::Http => "remote endpoint", + }) + } +} + +/// What kind of remote failure — each a different remedy (R6.4). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RemoteKind { + /// It could not be reached at all: DNS, connection refused, no route, a + /// transport that would not come up. Check the network and that the + /// service is up. + Unreachable, + /// The request went out and no answer came in time. The service may be + /// slow, or a leg beyond it silent; retrying may clear it. + Timeout, + /// It answered and refused our credentials (401/403, a refused + /// credential, an ACL denial). Re-authenticate or check the grant; the + /// network is fine. + AuthRejected, + /// It answered something this client cannot read: an unexpected shape, + /// an unknown type, a version it does not speak. Not retryable; one side + /// needs upgrading. + Contract, + /// It answered that the thing asked about does not exist. + NotFound, + /// It understood the request and declined it, for a reason it gave. + Refused { + /// The refusal's machine-readable code, when it sent one. + code: Option, + }, + /// It was reached and failed on its own side (a 5xx, an internal error): + /// its log has the cause. + Failed, +} + +impl std::fmt::Display for RemoteKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RemoteKind::Unreachable => f.write_str("unreachable"), + RemoteKind::Timeout => f.write_str("timed out"), + RemoteKind::AuthRejected => f.write_str("rejected our credentials"), + RemoteKind::Contract => f.write_str("contract mismatch"), + RemoteKind::NotFound => f.write_str("not found"), + RemoteKind::Refused { code: Some(code) } => write!(f, "refused [{code}]"), + RemoteKind::Refused { code: None } => f.write_str("refused"), + RemoteKind::Failed => f.write_str("failed"), + } + } +} + +impl RemoteKind { + /// Classify a VTA SDK error. + #[must_use] + pub fn from_vta(e: &vta_sdk::error::VtaError) -> Self { + use vta_sdk::error::VtaError as E; + if crate::vta_receive_leg::is_reply_timeout(e) { + return RemoteKind::Timeout; + } + match e { + E::Network(inner) if inner.is_timeout() => RemoteKind::Timeout, + E::Network(inner) if inner.is_decode() => RemoteKind::Contract, + E::Network(_) + | E::DidcommTransport(_) + | E::TspTransport(_) + | E::MediatorHandshakeFailed { .. } => RemoteKind::Unreachable, + E::RepliesNotArriving { .. } => RemoteKind::Timeout, + E::Auth(_) | E::Forbidden(_) => RemoteKind::AuthRejected, + E::NotFound(_) | E::Gone(_) => RemoteKind::NotFound, + E::Server { .. } | E::Unavailable { .. } | E::Other(_) => RemoteKind::Failed, + E::UnsupportedTransport(_) + | E::NoMatchingProtocol { .. } + | E::UnsupportedTaskType { .. } + | E::Protocol(_) + | E::Serialization(_) => RemoteKind::Contract, + E::Rejected { code, .. } | E::DidcommRemote { code, .. } => { + Self::from_refusal_code(code) + } + E::ConsentRequired { .. } => RemoteKind::Refused { + code: Some("consentRequired".to_string()), + }, + E::RateLimited { .. } => RemoteKind::Refused { + code: Some("rateLimited".to_string()), + }, + other => RemoteKind::Refused { + code: other.rejection_code().map(str::to_string), + }, + } + } + + /// Classify a refusal by its code: a Trust Task error code (`notFound`, + /// `permissionDenied`, `internalError`, …), possibly scoped + /// (`persona/attribute/get:notFound`), or a problem-report code. + #[must_use] + pub fn from_refusal_code(code: &str) -> Self { + let leaf = code.rsplit([':', '.']).next().unwrap_or(code); + match leaf { + "notFound" | "not-found" | "not_found" | "gone" => RemoteKind::NotFound, + "permissionDenied" | "forbidden" | "unauthorized" | "unauthenticated" + | "identityMismatch" | "untrusted" => RemoteKind::AuthRejected, + "internalError" + | "internal" + | "unavailable" + | "upstreamUnavailable" + | "upstream_unavailable" + | "taskFailed" => RemoteKind::Failed, + "unsupportedType" | "unsupportedVersion" | "malformed" | "malformedRequest" + | "schemaViolation" => RemoteKind::Contract, + _ => RemoteKind::Refused { + code: Some(code.to_string()), + }, + } + } + + /// Classify a messaging (ATM) error. + #[must_use] + pub fn from_atm(e: &ATMError) -> Self { + if let Some(status) = e.http_status() { + return Self::from_http_status(status.status); + } + match e { + ATMError::TransportError(_) + | ATMError::SSLError(_) + | ATMError::Disconnected(_) + | ATMError::MsgSendError(_) + | ATMError::MsgReceiveError(_) => RemoteKind::Unreachable, + ATMError::AuthenticationError(_) | ATMError::DIDAuth(_) | ATMError::ACLDenied(_) => { + RemoteKind::AuthRejected + } + ATMError::UnexpectedEnvelope(_) + | ATMError::AddressingMismatch(_) + | ATMError::DidcommError(..) => RemoteKind::Contract, + ATMError::ProblemReport(code, ..) | ATMError::MediatorError(code, _) => { + Self::from_refusal_code(code) + } + _ => RemoteKind::Failed, + } + } + + /// Classify an HTTP status a remote answered with. + #[must_use] + pub fn from_http_status(status: u16) -> Self { + match status { + 401 | 403 => RemoteKind::AuthRejected, + 404 | 410 => RemoteKind::NotFound, + 408 | 504 => RemoteKind::Timeout, + 415 | 422 => RemoteKind::Contract, + 429 => RemoteKind::Refused { + code: Some("rateLimited".to_string()), + }, + 500..=599 => RemoteKind::Failed, + other => RemoteKind::Refused { + code: Some(other.to_string()), + }, + } + } +} + +/// The class of an error, for a UI that has to say what kind of failure it is +/// and what to do (R6.4) without reading its text. [`OpenVTCError::class`] +/// gives it; [`class_of`] does the same through an `anyhow` chain. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum ErrorClass { + /// A remote service could not be reached. + Unreachable, + /// A remote service did not answer in time. + Timeout, + /// A remote service refused our credentials. + AuthRejected, + /// A remote service answered something this client cannot read. + Contract, + /// A remote service says the thing does not exist. + NotFound, + /// A remote service declined, with a reason. + Refused, + /// A remote service failed on its own side. + RemoteFailed, + /// Nothing remote: local configuration, storage, keys, input. + Local, +} + +impl ErrorClass { + /// Whether the failure is another service's (or the network's), as + /// opposed to local. + #[must_use] + pub fn is_remote(self) -> bool { + self != ErrorClass::Local + } + + /// One sentence on what to do about it. + #[must_use] + pub fn remedy(self) -> &'static str { + match self { + ErrorClass::Unreachable => { + "Check the network, and that the service is up and reachable from here." + } + ErrorClass::Timeout => { + "The request went out but no answer came in time — retry; if it persists, \ + check the service's log for the leg it was waiting on." + } + ErrorClass::AuthRejected => { + "The service was reached and refused our credentials — re-authenticate or \ + check the grant. The network is fine." + } + ErrorClass::Contract => { + "The service answered in a shape this client does not understand — one side \ + needs upgrading. Retrying will not help." + } + ErrorClass::NotFound => "The service does not know the thing asked about.", + ErrorClass::Refused => "The service declined; its reason is in the message.", + ErrorClass::RemoteFailed => { + "The service failed on its own side — its log has the cause." + } + ErrorClass::Local => "Nothing remote failed; the cause is on this machine.", + } + } +} + +impl From<&RemoteKind> for ErrorClass { + fn from(kind: &RemoteKind) -> Self { + match kind { + RemoteKind::Unreachable => ErrorClass::Unreachable, + RemoteKind::Timeout => ErrorClass::Timeout, + RemoteKind::AuthRejected => ErrorClass::AuthRejected, + RemoteKind::Contract => ErrorClass::Contract, + RemoteKind::NotFound => ErrorClass::NotFound, + RemoteKind::Refused { .. } => ErrorClass::Refused, + RemoteKind::Failed => ErrorClass::RemoteFailed, + } + } +} + +/// The class of an error that has been through `anyhow` or another wrapper: +/// the first [`OpenVTCError`], VTA or messaging error in its source chain +/// decides it; an error with none of them is [`ErrorClass::Local`]. +#[must_use] +pub fn class_of(e: &(dyn std::error::Error + 'static)) -> ErrorClass { + let mut cur: Option<&(dyn std::error::Error + 'static)> = Some(e); + while let Some(err) = cur { + if let Some(e) = err.downcast_ref::() { + return e.class(); + } + if let Some(e) = err.downcast_ref::() { + return ErrorClass::from(&RemoteKind::from_vta(e)); + } + if let Some(e) = err.downcast_ref::() { + return ErrorClass::from(&RemoteKind::from_atm(e)); + } + if let Some(e) = err.downcast_ref::() { + return e.class(); + } + cur = err.source(); + } + ErrorClass::Local } /// Which class of OS-secure-store failure occurred. @@ -199,6 +493,54 @@ impl std::fmt::Display for SecureStoreFault { } impl OpenVTCError { + /// A failure at the account's VTA, classified from the SDK's error. + /// `context` says what was being done ("persona attribute list failed"). + #[must_use] + pub fn vta(context: impl std::fmt::Display, e: &vta_sdk::error::VtaError) -> Self { + OpenVTCError::Remote { + service: RemoteService::Vta, + kind: RemoteKind::from_vta(e), + detail: format!("{context}: {e}"), + } + } + + /// A remote failure of a known kind. + #[must_use] + pub fn remote(service: RemoteService, kind: RemoteKind, detail: impl Into) -> Self { + OpenVTCError::Remote { + service, + kind, + detail: detail.into(), + } + } + + /// The class of this error, for a status line or a connection indicator + /// that has to say what kind of failure it is (R6.4). + /// + /// The older string-carrying variants are classified by what they are + /// documented to mean: [`Self::Vta`] is a connection/session fault, + /// [`Self::Auth`] a refused handshake, [`Self::Resolver`] a resolution that + /// did not complete. + #[must_use] + pub fn class(&self) -> ErrorClass { + match self { + OpenVTCError::Remote { kind, .. } => ErrorClass::from(kind), + OpenVTCError::ATM(e) => ErrorClass::from(&RemoteKind::from_atm(e)), + OpenVTCError::Vta(_) | OpenVTCError::Resolver(_) => ErrorClass::Unreachable, + OpenVTCError::Auth(_) => ErrorClass::AuthRejected, + _ => ErrorClass::Local, + } + } + + /// The remote failure's kind, when this is a [`Self::Remote`]. + #[must_use] + pub fn remote_kind(&self) -> Option<&RemoteKind> { + match self { + OpenVTCError::Remote { kind, .. } => Some(kind), + _ => None, + } + } + /// Classify a [`keyring_core::Error`] into a typed [`Self::SecureStore`]. /// /// Kept here rather than at the call sites so every keyring touchpoint @@ -310,6 +652,99 @@ mod tests { )); } + /// Each VTA failure lands in the class its remedy needs (R6.4). + #[test] + fn a_vta_error_is_classified_by_its_remedy() { + use vta_sdk::error::VtaError; + let cases = [ + ( + VtaError::DidcommTransport("connection refused".into()), + ErrorClass::Unreachable, + ), + ( + VtaError::TspTransport( + "timed out waiting for the TSP reply to request 'urn:uuid:1'".into(), + ), + ErrorClass::Timeout, + ), + ( + VtaError::RepliesNotArriving { + consecutive_timeouts: 3, + }, + ErrorClass::Timeout, + ), + (VtaError::Auth("expired".into()), ErrorClass::AuthRejected), + (VtaError::Forbidden("no".into()), ErrorClass::AuthRejected), + (VtaError::Protocol("bad shape".into()), ErrorClass::Contract), + (VtaError::NotFound("gone".into()), ErrorClass::NotFound), + ( + VtaError::Server { + status: 500, + body: String::new(), + }, + ErrorClass::RemoteFailed, + ), + (VtaError::Conflict("dup".into()), ErrorClass::Refused), + ]; + for (e, class) in cases { + let err = OpenVTCError::vta("doing a thing", &e); + assert_eq!(err.class(), class, "{e}"); + assert!(err.to_string().contains("doing a thing"), "{err}"); + } + } + + /// A refusal's code decides its kind, scoped or not. + #[test] + fn a_refusal_code_is_classified() { + assert_eq!( + RemoteKind::from_refusal_code("persona/attribute/get:notFound"), + RemoteKind::NotFound + ); + assert_eq!( + RemoteKind::from_refusal_code("permissionDenied"), + RemoteKind::AuthRejected + ); + assert_eq!( + RemoteKind::from_refusal_code("internalError"), + RemoteKind::Failed + ); + assert_eq!( + RemoteKind::from_refusal_code("vtc/members/renew:notMember"), + RemoteKind::Refused { + code: Some("vtc/members/renew:notMember".into()) + } + ); + } + + /// The class survives `anyhow`, so a CLI status line can ask for it. + #[test] + fn the_class_survives_anyhow() { + let err = anyhow::Error::new(OpenVTCError::remote( + RemoteService::Vta, + RemoteKind::Timeout, + "x", + )) + .context("while joining"); + assert_eq!(class_of(err.as_ref()), ErrorClass::Timeout); + let local = anyhow::anyhow!("a local failure"); + assert_eq!(class_of(local.as_ref()), ErrorClass::Local); + } + + /// The legacy string variants classify as they are documented. + #[test] + fn legacy_variants_keep_their_documented_class() { + assert_eq!( + OpenVTCError::Vta("x".into()).class(), + ErrorClass::Unreachable + ); + assert_eq!( + OpenVTCError::Auth("x".into()).class(), + ErrorClass::AuthRejected + ); + assert_eq!(OpenVTCError::Config("x".into()).class(), ErrorClass::Local); + assert!(!ErrorClass::Local.is_remote()); + } + #[test] fn test_error_debug_is_nonempty() { let err = OpenVTCError::BIP32("test".into()); diff --git a/openvtc-core/src/git_signing.rs b/openvtc-core/src/git_signing.rs index 014afe48..30eeb767 100644 --- a/openvtc-core/src/git_signing.rs +++ b/openvtc-core/src/git_signing.rs @@ -216,11 +216,15 @@ async fn timed( ) -> Result { match tokio::time::timeout(VTA_TIMEOUT, fut).await { Ok(Ok(v)) => Ok(v), - Ok(Err(e)) => Err(OpenVTCError::Config(format!("{what}: {e}"))), - Err(_) => Err(OpenVTCError::Config(format!( - "{what}: the VTA did not answer within {} s", - VTA_TIMEOUT.as_secs() - ))), + Ok(Err(e)) => Err(OpenVTCError::vta(what, &e)), + Err(_) => Err(OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Timeout, + format!( + "{what}: the VTA did not answer within {} s", + VTA_TIMEOUT.as_secs() + ), + )), } } @@ -309,10 +313,11 @@ async fn prove_remote_signing( let answer = tokio::time::timeout(VTA_TIMEOUT, sign_sshsig(client, &signer.vta_key_id, &hash)) .await .map_err(|_| { - OpenVTCError::Config(format!( - "the VTA did not sign within {} s", - VTA_TIMEOUT.as_secs() - )) + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Timeout, + format!("the VTA did not sign within {} s", VTA_TIMEOUT.as_secs()), + ) })? .map_err(|e| OpenVTCError::Config(format!("the VTA refused to sign: {e}")))?; let raw = match answer { @@ -403,11 +408,14 @@ pub async fn revoke_signer( match tokio::time::timeout(VTA_TIMEOUT, client.delete_acl(credential_did)).await { Ok(Ok(())) => Ok(true), Ok(Err(e)) if crate::vta_task::acl_subject_absent(&e) => Ok(false), - Ok(Err(e)) => Err(OpenVTCError::Config(format!( - "the VTA refused to revoke the signing credential: {e}" - ))), - Err(_) => Err(OpenVTCError::Config( - "the VTA did not answer the revocation in time".into(), + Ok(Err(e)) => Err(OpenVTCError::vta( + "the VTA refused to revoke the signing credential", + &e, + )), + Err(_) => Err(OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Timeout, + "the VTA did not answer the revocation in time", )), } } diff --git a/openvtc/src/state_handler/message_dispatch.rs b/openvtc-core/src/inbound/mod.rs similarity index 50% rename from openvtc/src/state_handler/message_dispatch.rs rename to openvtc-core/src/inbound/mod.rs index 2a0e9110..1b6d9a2a 100644 --- a/openvtc/src/state_handler/message_dispatch.rs +++ b/openvtc-core/src/inbound/mod.rs @@ -1,21 +1,20 @@ -//! Inbound DIDComm message dispatch for the TUI. +//! Inbound message dispatch: what this client does with a message a peer or +//! community sent it. //! -//! Messages that don't need human input are auto-processed. -//! Messages requiring user decisions are queued as tasks in the inbox. +//! Messages that need no human input are applied to [`Config`] here; messages +//! that need a decision are queued as inbox tasks. Anything this cannot do +//! itself — tear down a session, show a challenge, hand a reply to a waiting +//! view — is reported up in [`InboundEffects`] for the caller (the TUI's +//! runtime loop, or a headless command via [`apply_inline`]) to act on. //! -//! The pure protocol logic (validators, the join-receipt / credential-issue -//! handlers, the VRC vetting + proof verification, and the replay guard) lives -//! in [`openvtc_core::messaging`] so it is testable without the TUI crate. This -//! module keeps only the async I/O orchestrator [`process_inbound_message`], -//! which imports and calls into core. +//! Moved here from the CLI crate (issue #530) so it has no TUI dependency and +//! core's end-to-end harness can drive it. use std::sync::Arc; -use affinidi_tdk::{TDK, didcomm::Message}; -use dtg_credentials::DTGCredential; -use openvtc_core::didcomm::Messaging; -use openvtc_core::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE; -use openvtc_core::messaging::{ +use crate::didcomm::Messaging; +use crate::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE; +use crate::messaging::{ SeenMessages, check_message_age, check_task_capacity, create_finalize_message, credential_in_issue, credential_issue_admissible, handle_community_profile_show_response, handle_credential_issue, handle_join_status_response, handle_join_submit_receipt, @@ -23,11 +22,9 @@ use openvtc_core::messaging::{ is_trust_task_error_type, read_problem_report, require_thid, validate_did, verify_inbound_vrc, vet_vrc_issued, }; -use openvtc_core::personhood::{ - PERSONHOOD_ASSERT_RESPONSE_TYPE, PERSONHOOD_CHALLENGE_RESPONSE_TYPE, -}; -use openvtc_core::vetting::protocol::JoinProtocol; -use openvtc_core::{ +use crate::personhood::{PERSONHOOD_ASSERT_RESPONSE_TYPE, PERSONHOOD_CHALLENGE_RESPONSE_TYPE}; +use crate::vetting::protocol::JoinProtocol; +use crate::{ MessageType, config::{Config, account::VtcDid}, logs::LogFamily, @@ -35,7 +32,13 @@ use openvtc_core::{ tasks::TaskType, vrc::VRCRequestReject, }; +use affinidi_tdk::{TDK, didcomm::Message}; +use dtg_credentials::DTGCredential; use tracing::{debug, info, warn}; + +pub mod registry; + +use registry::Family; use vta_sdk::protocols::PROBLEM_REPORT_TYPE; use vta_sdk::protocols::credential_exchange::ISSUE as CREDENTIAL_ISSUE_TYPE; use vta_sdk::protocols::join_requests::{ @@ -59,10 +62,10 @@ pub(crate) async fn issue_member_vmc_for( config: &Config, tdk: &TDK, vtc_did: &str, - persona_id: openvtc_core::config::account::PersonaId, + persona_id: crate::config::account::PersonaId, closes_request: Option, -) -> Result<(uuid::Uuid, serde_json::Value), openvtc_core::errors::OpenVTCError> { - use openvtc_core::errors::OpenVTCError; +) -> Result<(uuid::Uuid, serde_json::Value), crate::errors::OpenVTCError> { + use crate::errors::OpenVTCError; // The grant we are acknowledging, as the community sent it. Without one // there is nothing to acknowledge: an acknowledgement names a specific // grant by digest, and a community that has issued us none has no @@ -72,7 +75,7 @@ pub(crate) async fn issue_member_vmc_for( let grant = membership .and_then(|c| { c.credentials - .get(&openvtc_core::CredentialKind::Membership) + .get(&crate::CredentialKind::Membership) .cloned() }) .ok_or_else(|| { @@ -98,12 +101,12 @@ pub(crate) async fn issue_member_vmc_for( // join poll does: it is the hop the routing layer seals to, and the // community's document may have changed since the submit. let tsp_mediator = if over_tsp { - openvtc_core::config::peer_tsp_mediator(vtc_did).await + crate::config::peer_tsp_mediator(vtc_did).await } else { None }; - openvtc_core::members::issue_and_send_member_vmc( - &openvtc_core::members::Delivery { + crate::members::issue_and_send_member_vmc( + &crate::members::Delivery { atm, profile: &profile, member_did: &member_did, @@ -121,8 +124,22 @@ pub(crate) async fn issue_member_vmc_for( /// Whether a document of `typ` may be a reply to a capability request. fn is_capability_reply_type(typ: &str) -> bool { - typ.starts_with("https://trusttasks.org/spec/governance/capability/") - || is_trust_task_error_type(typ) + typ.starts_with(crate::capabilities::CAPABILITY_TYPE_PREFIX) || is_trust_task_error_type(typ) +} + +/// A community's reply to a request of ours, as handed to the view waiting on +/// it: who sent it, who signed it, and the thread it answers. The view takes a +/// reply only from the community it asked. +#[derive(Debug, Clone)] +pub struct ThreadedReply { + /// The transport-authenticated sender. + pub from: String, + /// The document's `issuer`. + pub issuer: Option, + /// The request id the reply threads on. + pub thid: String, + /// The classified reply. + pub reply: R, } /// What an inbound message asks the loop to do, beyond mutating `Config`. @@ -137,40 +154,40 @@ fn is_capability_reply_type(typ: &str) -> bool { 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)>, + pub inactivated: Vec<(VtcDid, crate::config::account::PersonaId)>, /// Capability replies, keyed by the request id they thread on. /// `(sender, thread id, reply)` — the view takes a reply only from the /// community it asked. - pub capability_replies: Vec<(String, String, openvtc_core::capabilities::CapabilityReply)>, + pub capability_replies: Vec<(String, String, crate::capabilities::CapabilityReply)>, /// `git-ns/*` replies for the Repos panel, keyed by the request id they /// thread on. - pub git_ns_replies: Vec, + pub git_ns_replies: Vec>, /// `vtc/rooms/*` replies for the Rooms panel, keyed by the request id they thread on. - pub rooms_replies: Vec, + pub rooms_replies: Vec>, /// `vtc/elections/*` replies for the Elections panel, keyed by the request id they thread /// on. - pub elections_replies: Vec, + pub elections_replies: Vec>, /// Personhood challenges the community answered with. Display state with a /// ten-minute life — never persisted. - pub personhood_challenges: Vec, + pub personhood_challenges: Vec, /// Communities' answers to questions we put to them — a directory page, a /// stored profile, a resent grant, a manifest — for whoever is waiting. - pub vetting_answers: Vec, + pub vetting_answers: Vec, /// Vetters' grants to check for revocation. The check fetches over HTTPS, /// so the loop runs it as a background job. - pub vetting_grant_checks: Vec, + pub vetting_grant_checks: Vec, /// How renewals we asked for ended (`vtc/members/renew/0.1`), keyed by the /// community — the panel's status line says what happened. - pub renewals: Vec<(String, openvtc_core::renewal::RenewalOutcome)>, + pub renewals: Vec<(String, crate::renewal::RenewalOutcome)>, /// Documents that may be a witness's answer to a witnessing in progress: its session or /// submission `#response`, or a `trust-task-error`. The loop hands each to the witnessing /// waiting on its thread, which verifies it as the witness's before reading it; one on a /// thread nobody waits on is dropped there. - pub witness_replies: Vec, + pub witness_replies: Vec, /// Notifications (`notifications/notify`, `notifications/relay`) addressed to one of our /// personas, exactly as received. The loop hands each to the member's VTA and answers the /// sender (VTI-NTF-054); nothing here reads, filters or acts on them. - pub notifications: Vec, + pub notifications: Vec, /// A message whose handling waits on a network-bound check (a DID resolve, /// a status-list fetch). The loop runs [`VerifyJob::run`] off the loop and /// hands the message back with the result; nothing about it is applied @@ -207,7 +224,7 @@ pub enum VerifyJob { persona_proof: Option, peer: String, thid: String, - role: openvtc_core::relationships::BindingRole, + role: crate::relationships::BindingRole, }, /// A VRC's proof against its issuer's key. Vrc(Box), @@ -225,10 +242,10 @@ pub enum VerifyJob { /// membership it asks about Active, say. Barrier, /// A check that never finishes (tests of the queue's timeout). - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] Hang, /// A check that panics (tests of the queue's supervision). - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] Panic, } @@ -237,19 +254,16 @@ pub enum VerifyJob { pub enum PreVerified { Credential( Result< - openvtc_core::issued_credential::VerifiedIssuedCredential, - openvtc_core::issued_credential::IssuedCredentialError, + crate::issued_credential::VerifiedIssuedCredential, + crate::issued_credential::IssuedCredentialError, >, ), Operational( - Result< - openvtc_core::operational::VerifiedOperational, - openvtc_core::operational::OperationalError, - >, + Result, ), - DidBinding(Result<(), openvtc_core::relationships::DidBindingError>), + DidBinding(Result<(), crate::relationships::DidBindingError>), Vrc(Result<(), String>), - Renewal(Result), + Renewal(Result), /// Nothing was checked ([`VerifyJob::Barrier`]). Barrier, } @@ -260,7 +274,7 @@ pub enum Arrival<'a> { /// queued for a check ahead of this one, which it must not overtake. New { waiting: &'a dyn Fn(&str) -> bool }, /// Queued for its check before a restart - /// ([`openvtc_core::config::protected_config::ProtectedConfig::deferred_inbound`]). + /// ([`crate::config::protected_config::ProtectedConfig::deferred_inbound`]). /// It passed the age and replay gates when it arrived; it is set aside /// again — for its check if it still passes the local checks, else to wait /// its turn and be refused by its handler then. It is never handled @@ -270,12 +284,12 @@ pub enum Arrival<'a> { Returning(PreVerified), } -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] fn nobody_waiting(_: &str) -> bool { false } -#[cfg(test)] +#[cfg(any(test, feature = "test-support"))] impl Arrival<'static> { /// Just arrived, with nothing queued ahead of it. pub const FRESH: Arrival<'static> = Arrival::New { @@ -297,7 +311,7 @@ impl VerifyJob { let now = chrono::Utc::now(); match self { VerifyJob::Credential { document, sender } => PreVerified::Credential( - openvtc_core::issued_credential::verify_issued_delivery( + crate::issued_credential::verify_issued_delivery( &document, &sender, tdk.did_resolver(), @@ -315,13 +329,13 @@ impl VerifyJob { // The replay check is the loop's, at apply time, against the // live set; an empty set here only skips it. PreVerified::Operational( - openvtc_core::operational::verify_operational( + crate::operational::verify_operational( &document, &sender, &ours, &typ, tdk.did_resolver(), - &openvtc_core::operational::SeenDocuments::default(), + &crate::operational::SeenDocuments::default(), now, ) .await, @@ -336,7 +350,7 @@ impl VerifyJob { thid, role, } => PreVerified::DidBinding( - openvtc_core::relationships::verify_did_binding( + crate::relationships::verify_did_binding( &did, did_proof.as_ref(), &persona, @@ -358,7 +372,7 @@ impl VerifyJob { } => { let ours: Vec<&str> = our_dids.iter().map(String::as_str).collect(); PreVerified::Renewal( - openvtc_core::renewal::verify_renewal_response( + crate::renewal::verify_renewal_response( &document, &sender, &ours, @@ -369,9 +383,9 @@ impl VerifyJob { ) } VerifyJob::Barrier => PreVerified::Barrier, - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] VerifyJob::Hang => std::future::pending().await, - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] VerifyJob::Panic => panic!("a check that panics"), } } @@ -382,25 +396,25 @@ impl VerifyJob { pub fn unfinished(&self) -> PreVerified { match self { VerifyJob::Credential { .. } => PreVerified::Credential(Err( - openvtc_core::issued_credential::IssuedCredentialError::CheckUnfinished, - )), - VerifyJob::DidBinding { .. } => PreVerified::DidBinding(Err( - openvtc_core::relationships::DidBindingError::CheckUnfinished, + crate::issued_credential::IssuedCredentialError::CheckUnfinished, )), + VerifyJob::DidBinding { .. } => { + PreVerified::DidBinding(Err(crate::relationships::DidBindingError::CheckUnfinished)) + } VerifyJob::Vrc(_) => PreVerified::Vrc(Err( "its proof's check did not finish (timed out or failed)".to_string(), )), VerifyJob::Renewal { .. } => { - PreVerified::Renewal(Err(openvtc_core::renewal::RenewalError::CheckUnfinished)) + PreVerified::Renewal(Err(crate::renewal::RenewalError::CheckUnfinished)) } VerifyJob::Barrier => PreVerified::Barrier, - #[cfg(test)] - VerifyJob::Hang | VerifyJob::Panic => PreVerified::Operational(Err( - openvtc_core::operational::OperationalError::CheckUnfinished, - )), - VerifyJob::Operational { .. } => PreVerified::Operational(Err( - openvtc_core::operational::OperationalError::CheckUnfinished, - )), + #[cfg(any(test, feature = "test-support"))] + VerifyJob::Hang | VerifyJob::Panic => { + PreVerified::Operational(Err(crate::operational::OperationalError::CheckUnfinished)) + } + VerifyJob::Operational { .. } => { + PreVerified::Operational(Err(crate::operational::OperationalError::CheckUnfinished)) + } } } @@ -411,14 +425,14 @@ impl VerifyJob { matches!( self, VerifyJob::DidBinding { - role: openvtc_core::relationships::BindingRole::Request, + role: crate::relationships::BindingRole::Request, .. } ) } /// Whether this is a [`VerifyJob::Barrier`] (nothing to check). - #[cfg(test)] + #[cfg(any(test, feature = "test-support"))] #[must_use] pub fn is_barrier(&self) -> bool { matches!(self, VerifyJob::Barrier) @@ -466,125 +480,84 @@ fn verification_job( .collect(), typ: typ.to_string(), }; - if typ == CREDENTIAL_ISSUE_TYPE { - let credential = credential_in_issue(message)?; - // Issued by the community that sent it, to one of our personas, and - // that community is one we hold a record with. (Which membership, - // and whether it is live, the handler checks again once verified; a - // vetter's grant passes here too.) A vetter's statement is vetting's, - // checked by vetting. - let to_us = credential - .pointer("/credentialSubject/id") - .and_then(serde_json::Value::as_str) - .is_some_and(|subject| config.account.persona_id_for_did(subject).is_some()); - let from_issuer = openvtc_core::issued_credential::issuer_of(&credential) == Some(from_did); - return (member && to_us && from_issuer).then(|| VerifyJob::Credential { - document: message.body.clone(), - sender: from_did.to_string(), - }); - } - // A community's answer to a vetting question: only from a community we - // have business with — a question outstanding, an application, or a - // membership (the same test vetting applies before taking one). - if openvtc_core::vetting::inbound::is_community_answer_type(typ) { - let business = config.private.vetting.asked(from_did) - || config - .private - .vetting - .applications - .iter() - .any(|a| a.community == from_did) - || member; - return business.then(operational); - } - // Our renewal's answer: only one threading on a renewal we asked this - // community for. It carries credentials, so it is checked as both an - // operational document and two issued credentials. - if typ == openvtc_core::renewal::MEMBERS_RENEW_RESPONSE_TYPE { - let asked = message - .thid - .as_deref() - .is_some_and(|thid| openvtc_core::renewal::is_pending(from_did, thid)); - return (member && asked).then(|| VerifyJob::Renewal { - document: message.body.clone(), - sender: from_did.to_string(), - our_dids: config - .account - .personas - .values() - .map(|p| p.did.clone()) - .collect(), - }); - } - // An election answer from the community holding an election a notification told us of - // (VTI-NTF-063), which may be one we hold no membership with: only on a thread we sent it. - if !member && openvtc_core::elections::is_reply_type(typ) { - return awaited_election_answer(message, from_did).then(operational); - } - if is_capability_reply_type(typ) - || openvtc_core::git_ns::is_reply_type(typ) - || openvtc_core::community_rooms::is_reply_type(typ) - || openvtc_core::elections::is_reply_type(typ) - || JoinProtocol::is_submit_response(typ) - || [ - MEMBER_REMOVAL_NOTICE_TYPE, - MEMBER_REQUEST_VMC_TYPE, - JOIN_REQUEST_SUBMIT_RECEIPT_TYPE, - JOIN_REQUEST_STATUS_RESPONSE_TYPE, - COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE, - ] - .contains(&typ) - { - return member.then(operational); - } - match MessageType::try_from(message).ok()? { - MessageType::RelationshipRequest => { - let body: openvtc_core::relationships::RelationshipRequestBody = - serde_json::from_value(message.body.clone()).ok()?; - validate_did(&body.did).ok()?; - relationship_request_admissible( - config, - &Arc::new(message.id.clone()), - &Arc::new(from_did.to_string()), - ) - .ok()?; - Some(VerifyJob::DidBinding { - did: body.did, - did_proof: body.did_proof, - persona: from_did.to_string(), - persona_proof: body.persona_proof, - peer: recipient_did.to_string(), - thid: message.id.clone(), - role: openvtc_core::relationships::BindingRole::Request, + let member_operational = || member.then(operational); + match registry::classify(typ)? { + Family::CredentialIssue => { + let credential = credential_in_issue(message)?; + // Issued by the community that sent it, to one of our personas, and + // that community is one we hold a record with. (Which membership, + // and whether it is live, the handler checks again once verified; a + // vetter's grant passes here too.) A vetter's statement is vetting's, + // checked by vetting. + let to_us = credential + .pointer("/credentialSubject/id") + .and_then(serde_json::Value::as_str) + .is_some_and(|subject| config.account.persona_id_for_did(subject).is_some()); + let from_issuer = crate::issued_credential::issuer_of(&credential) == Some(from_did); + (member && to_us && from_issuer).then(|| VerifyJob::Credential { + document: message.body.clone(), + sender: from_did.to_string(), }) } - MessageType::RelationshipRequestAccepted => { - let body: RelationshipAcceptBody = serde_json::from_value(message.body.clone()).ok()?; - validate_did(&body.did).ok()?; - let thid = message.thid.clone()?; - // It answers a request of ours still waiting, from the party it - // went to. - config - .private - .relationships - .awaiting( - &Arc::new(thid.clone()), - RelationshipState::RequestSent, - from_did, - ) - .is_some() - .then_some(())?; - Some(VerifyJob::DidBinding { - did: body.did, - did_proof: body.did_proof, - persona: from_did.to_string(), - persona_proof: body.persona_proof, - peer: recipient_did.to_string(), - thid, - role: openvtc_core::relationships::BindingRole::Accept, + // A community's answer to a vetting question: only from a community we + // have business with — a question outstanding, an application, or a + // membership (the same test vetting applies before taking one). The + // rest of vetting's traffic is checked by vetting. + Family::Vetting if crate::vetting::inbound::is_community_answer_type(typ) => { + let business = config.private.vetting.asked(from_did) + || config + .private + .vetting + .applications + .iter() + .any(|a| a.community == from_did) + || member; + business.then(operational) + } + // Our renewal's answer: only one threading on a renewal we asked this + // community for. It carries credentials, so it is checked as both an + // operational document and two issued credentials. + Family::RenewalResponse => { + let asked = message + .thid + .as_deref() + .is_some_and(|thid| crate::renewal::is_pending(from_did, thid)); + (member && asked).then(|| VerifyJob::Renewal { + document: message.body.clone(), + sender: from_did.to_string(), + our_dids: config + .account + .personas + .values() + .map(|p| p.did.clone()) + .collect(), }) } - MessageType::VRCIssued => { + // An election answer — or a refusal, which may be one — from the + // community holding an election a notification told us of + // (VTI-NTF-063), which may be one we hold no membership with: only on + // a thread we sent it. + Family::ElectionsReply | Family::TrustTaskError if !member => { + awaited_election_answer(message, from_did).then(operational) + } + Family::CapabilityReply + | Family::GitNsReply + | Family::RoomsReply + | Family::ElectionsReply + | Family::TrustTaskError + | Family::JoinVerdict + | Family::MemberRemovalNotice + | Family::MemberRequestVmc + | Family::JoinSubmitReceipt + | Family::JoinStatusResponse + | Family::CommunityProfileResponse => member_operational(), + Family::RelationshipRequest => { + relationship_request_job(config, message, from_did, recipient_did) + } + Family::RelationshipRequestAccepted => { + relationship_accept_job(config, message, from_did, recipient_did) + } + Family::VrcIssued => { let vrc: DTGCredential = serde_json::from_value(message.body.clone()).ok()?; // An established relationship, and the issuer pinned to it. vet_vrc_issued( @@ -597,10 +570,82 @@ fn verification_job( .ok()?; Some(VerifyJob::Vrc(Box::new(vrc))) } - _ => None, + Family::Notification + | Family::WitnessReply + | Family::Vetting + | Family::ProblemReport + | Family::PersonhoodChallengeResponse + | Family::PersonhoodAssertResponse + | Family::MemberVmcResponse + | Family::BindingEnvelope + | Family::RelationshipRequestRejected + | Family::RelationshipRequestFinalize + | Family::TrustPing + | Family::TrustPong + | Family::VrcRequest + | Family::VrcRequestRejected => None, } } +/// A relationship request's binding check, if it could be queued at all. +fn relationship_request_job( + config: &Config, + message: &Message, + from_did: &str, + recipient_did: &str, +) -> Option { + let body: crate::relationships::RelationshipRequestBody = + serde_json::from_value(message.body.clone()).ok()?; + validate_did(&body.did).ok()?; + relationship_request_admissible( + config, + &Arc::new(message.id.clone()), + &Arc::new(from_did.to_string()), + ) + .ok()?; + Some(VerifyJob::DidBinding { + did: body.did, + did_proof: body.did_proof, + persona: from_did.to_string(), + persona_proof: body.persona_proof, + peer: recipient_did.to_string(), + thid: message.id.clone(), + role: crate::relationships::BindingRole::Request, + }) +} + +/// A relationship accept's binding check, if it answers a request of ours +/// still waiting, from the party it went to. +fn relationship_accept_job( + config: &Config, + message: &Message, + from_did: &str, + recipient_did: &str, +) -> Option { + let body: RelationshipAcceptBody = serde_json::from_value(message.body.clone()).ok()?; + validate_did(&body.did).ok()?; + let thid = message.thid.clone()?; + config + .private + .relationships + .awaiting( + &Arc::new(thid.clone()), + RelationshipState::RequestSent, + from_did, + ) + .is_some() + .then_some(())?; + Some(VerifyJob::DidBinding { + did: body.did, + did_proof: body.did_proof, + persona: from_did.to_string(), + persona_proof: body.persona_proof, + peer: recipient_did.to_string(), + thid, + role: crate::relationships::BindingRole::Accept, + }) +} + /// Whether an inbound relationship request from `from_did` (task id /// `task_id`) could be queued at all: room for the task, under the /// relationship limit, no relationship with the sender already, and no @@ -644,32 +689,29 @@ fn awaited_election_answer(message: &Message, from_did: &str) -> bool { .get("threadId") .and_then(serde_json::Value::as_str) }); - thread.is_some_and(|t| openvtc_core::elections::is_awaited(from_did, t)) + thread.is_some_and(|t| crate::elections::is_awaited(from_did, t)) } /// An operational document that was not checked. static NOT_CHECKED: Result< - openvtc_core::operational::VerifiedOperational, - openvtc_core::operational::OperationalError, -> = Err(openvtc_core::operational::OperationalError::NotChecked); + crate::operational::VerifiedOperational, + crate::operational::OperationalError, +> = Err(crate::operational::OperationalError::NotChecked); /// The operational result the off-loop check produced, or "not checked". fn pre_operational( pre: &Option, -) -> Result< - openvtc_core::operational::VerifiedOperational, - openvtc_core::operational::OperationalError, -> { +) -> Result { match pre { Some(PreVerified::Operational(r)) => r.clone(), - _ => Err(openvtc_core::operational::OperationalError::NotChecked), + _ => Err(crate::operational::OperationalError::NotChecked), } } /// Whether a community's reply to a question of ours (capability, git-ns) may /// be taken: it must be the community's signed operational document — /// `authentication` key, addressed to the persona it arrived for, fresh, not -/// seen before ([`openvtc_core::operational`]). That holds for a refusal +/// seen before ([`crate::operational`]). That holds for a refusal /// (`trust-task-error`) as much as for a success: an unsigned refusal is /// ignored, with a log line. The view then also checks the sender is the /// community it asked. @@ -702,7 +744,7 @@ fn community_reply_proven( // election a notification told us of (VTI-NTF-063). Ok(_) if config.account.memberships_for(from_did).is_empty() - && !(openvtc_core::elections::is_reply_type(&message.typ) + && !(crate::elections::is_reply_type(&message.typ) && awaited_election_answer(message, from_did)) => { warn!(typ = %message.typ, "reply from a community we hold no membership with — ignored"); @@ -738,14 +780,11 @@ fn community_document( config: &Config, pre: &Option, from_did: &str, -) -> Result< - openvtc_core::operational::VerifiedOperational, - openvtc_core::operational::OperationalError, -> { +) -> Result { // A community we hold no membership with (Pending included) has asked // nothing of ours to answer. if config.account.memberships_for(from_did).is_empty() { - return Err(openvtc_core::operational::OperationalError::NoStanding); + return Err(crate::operational::OperationalError::NoStanding); } let verified = pre_operational(pre)?; verified.check(&config.private.seen_documents, chrono::Utc::now())?; @@ -755,7 +794,7 @@ fn community_document( /// Commit a verified community reply that matched something of ours. fn commit_community_document( config: &mut Config, - verified: openvtc_core::operational::VerifiedOperational, + verified: crate::operational::VerifiedOperational, ) { if let Err(e) = verified.commit(&mut config.private.seen_documents, chrono::Utc::now()) { warn!(reason = %e, "community reply acted on but not recorded"); @@ -776,10 +815,10 @@ fn commit_community_document( /// nothing is ever acted on before its check has passed. /// /// `authenticated` is the sender the transport bound to the frame -/// ([`openvtc_core::didcomm::DIDCommEvent::InboundMessage`]). The message is +/// ([`crate::didcomm::DIDCommEvent::InboundMessage`]). The message is /// handled as that sender only: one with no authenticated sender, or whose /// `from` names someone else, is dropped before any handler sees it -/// ([`openvtc_core::didcomm::bound_sender`]). +/// ([`crate::didcomm::bound_sender`]). #[allow(clippy::too_many_arguments)] pub async fn process_inbound_message( config: &mut Config, @@ -820,20 +859,6 @@ async fn process_inbound( effects: &mut InboundEffects, arrival: Arrival<'_>, ) -> Result { - let InboundEffects { - inactivated, - capability_replies, - git_ns_replies, - rooms_replies, - elections_replies, - personhood_challenges, - vetting_answers, - vetting_grant_checks, - renewals, - witness_replies, - notifications, - deferred, - } = effects; // A message handed back with its check's result (or restored from before // a restart) already passed the age and replay gates on arrival; running // them again would drop it as a replay of itself. @@ -862,12 +887,11 @@ async fn process_inbound( // inbox task, no reply, no receipt, no task removed. It selects the record // a message is about; decisions with lasting effect (a credential stored, // a membership ended, a relationship bound) are further gated on a proof - // by that party (`openvtc_core::proof_check`). + // by that party (`crate::proof_check`). // // Before the replay cache: a message handled as nobody must not take up // the id of one that is, or the real one would be dropped as its replay. - let from_did = match openvtc_core::didcomm::bound_sender(message.from.as_deref(), authenticated) - { + let from_did = match crate::didcomm::bound_sender(message.from.as_deref(), authenticated) { Ok(did) => Arc::new(did), Err(reason) => { warn!( @@ -931,8 +955,8 @@ async fn process_inbound( // community replies in the document's own type today and in the envelope // once it follows the binding (§5) fully — this reads both, the same way // (VTI #1687, Keyring VTI-42). - let opened = openvtc_core::didcomm::open_didcomm_envelope(message); - if opened.is_none() && message.typ == openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE { + let opened = crate::didcomm::open_didcomm_envelope(message); + if opened.is_none() && message.typ == crate::capabilities::TRUST_TASK_ENVELOPE_TYPE { warn!(id = %message.id, from = %from_did, "binding envelope carries no typed document — dropped"); return Ok(false); } @@ -962,7 +986,7 @@ async fn process_inbound( (None, _) => None, }; if let Some(job) = job { - *deferred = Some(Deferred { + effects.deferred = Some(Deferred { message: arrived.clone(), job, }); @@ -974,18 +998,102 @@ async fn process_inbound( Arrival::New { .. } | Arrival::Restored => None, }; - // Computed at most once per message (see `community_reply_proven`). - let mut reply_proven: Option = None; + let Some(family) = registry::classify(&message.typ) else { + warn!(typ = %message.typ, "unknown message type — ignoring"); + return Ok(false); + }; + let mut cx = Cx { + tdk, + service, + message, + from_did, + recipient_did, + recipient_persona, + pre, + }; + let c = &mut cx; + match family { + Family::Notification => notification(config, c, effects), + Family::CapabilityReply => capability_reply(config, c, effects), + Family::GitNsReply => git_ns_reply(config, c, effects), + Family::RoomsReply => rooms_reply(config, c, effects), + Family::ElectionsReply => elections_reply(config, c, effects), + Family::WitnessReply => witness_reply(c, effects), + Family::Vetting => vetting(config, c, effects).await, + Family::JoinSubmitReceipt => join_submit_receipt(config, c), + Family::CredentialIssue => credential_issue(config, c, effects).await, + Family::JoinVerdict => join_verdict(config, c, effects), + Family::ProblemReport => problem_report(config, c, effects).await, + Family::RenewalResponse => renewal_response(config, c, effects).await, + Family::TrustTaskError => trust_task_error(config, c, effects).await, + Family::JoinStatusResponse => join_status_response(config, c, effects), + Family::CommunityProfileResponse => community_profile_response(config, c), + Family::PersonhoodChallengeResponse => personhood_challenge(c, effects), + Family::PersonhoodAssertResponse => personhood_assert(c), + Family::MemberVmcResponse => member_vmc_response(config, c), + Family::MemberRequestVmc => member_request_vmc(config, c).await, + Family::MemberRemovalNotice => member_removal_notice(config, c, effects), + // Reached only by an envelope `open_didcomm_envelope` could not open + // (one that could has been routed as the document it carries). + Family::BindingEnvelope => Ok(unclaimed(message)), + Family::RelationshipRequest => peer(config, c, MessageType::RelationshipRequest).await, + Family::RelationshipRequestRejected => { + peer(config, c, MessageType::RelationshipRequestRejected).await + } + Family::RelationshipRequestAccepted => { + peer(config, c, MessageType::RelationshipRequestAccepted).await + } + Family::RelationshipRequestFinalize => { + peer(config, c, MessageType::RelationshipRequestFinalize).await + } + Family::TrustPing => peer(config, c, MessageType::TrustPing).await, + Family::TrustPong => peer(config, c, MessageType::TrustPong).await, + Family::VrcRequest => peer(config, c, MessageType::VRCRequest).await, + Family::VrcRequestRejected => peer(config, c, MessageType::VRCRequestRejected).await, + Family::VrcIssued => peer(config, c, MessageType::VRCIssued).await, + } +} + +/// What every family's handler reads about the message in hand. +struct Cx<'a> { + tdk: &'a TDK, + service: &'a Messaging, + /// The message as routed (opened out of its binding envelope). + message: &'a Message, + /// The authenticated sender. + from_did: Arc, + /// The persona it was addressed to — the DID any reply goes out as. + recipient_did: String, + recipient_persona: Option, + /// The result of its off-loop check, if it had one. + pre: Option, +} +/// A message of a routed family that its handler did not take: logged, and +/// nothing changes. +fn unclaimed(message: &Message) -> bool { + warn!(typ = %message.typ, "inbound message not taken by its handler — ignoring"); + false +} + +/// [`Family::Notification`]. +fn notification( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let InboundEffects { notifications, .. } = effects; // A notification addressed to one of our personas (VTI-NTF-054): handed to the member's // VTA exactly as received, whoever sent it — no acceptance policy here, which would be // declining on the principal's behalf. The loop deposits it and answers the sender; nothing // else is done because of it (VTI-NTF-053). - if openvtc_core::notifications::is_notification_type(&message.typ) { - match openvtc_core::notifications::addressee_of(&message.body) + if crate::notifications::is_notification_type(&message.typ) { + match crate::notifications::addressee_of(&message.body) .filter(|addressee| config.account.persona_id_for_did(addressee).is_some()) { - Some(addressee) => notifications.push(openvtc_core::notifications::Received { + Some(addressee) => notifications.push(crate::notifications::Received { document: message.body.clone(), sender: from_did.to_string(), addressee: addressee.to_string(), @@ -1000,7 +1108,24 @@ async fn process_inbound( } return Ok(false); } + Ok(unclaimed(message)) +} +/// [`Family::CapabilityReply`]. +fn capability_reply( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let pre = cx.pre.take(); + // Computed at most once per message (see `community_reply_proven`). + let mut reply_proven: Option = None; + let InboundEffects { + capability_replies, .. + } = effects; // Capability replies (governance/capability/*), in either carriage: hand // them to the state loop keyed by the document's `threadId` (== our request // id). This is a fan-in point waiting on nothing in particular, so the @@ -1013,9 +1138,8 @@ async fn process_inbound( // view *and* falls through to the handlers below; each side drops an error // that threads on nothing of its own. if is_capability_reply_type(&message.typ) - && let Some((thid, doc)) = - openvtc_core::capabilities::parse_envelope_document(&message.body) - && let Some(reply) = openvtc_core::capabilities::parse_capability_reply(&doc, &thid) + && let Some((thid, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some(reply) = crate::capabilities::parse_capability_reply(&doc, &thid) && community_reply_proven( config, &pre, @@ -1030,15 +1154,30 @@ async fn process_inbound( return Ok(false); } } + Ok(unclaimed(message)) +} +/// [`Family::GitNsReply`]. +fn git_ns_reply( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let pre = cx.pre.take(); + // Computed at most once per message (see `community_reply_proven`). + let mut reply_proven: Option = None; + let InboundEffects { git_ns_replies, .. } = effects; // Git namespace replies (git-ns/*), in either carriage, for the Repos // panel — keyed by the document's `threadId` and correlated there against // the one request the panel has outstanding; anything else is dropped. // A `trust-task-error` is offered here as well and falls through, for the // same reason as above. - if openvtc_core::git_ns::is_reply_type(&message.typ) - && let Some((_, doc)) = openvtc_core::capabilities::parse_envelope_document(&message.body) - && let Some((thid, reply)) = openvtc_core::git_ns::parse_reply(&doc) + if crate::git_ns::is_reply_type(&message.typ) + && let Some((_, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some((thid, reply)) = crate::git_ns::parse_reply(&doc) && community_reply_proven( config, &pre, @@ -1050,7 +1189,7 @@ async fn process_inbound( { // The sender and the document's issuer travel with the answer: the // Repos view takes one only from the community it asked. - git_ns_replies.push(crate::state_handler::repos_actions::InboundReply { + git_ns_replies.push(ThreadedReply { from: from_did.to_string(), issuer: doc.issuer.clone(), thid, @@ -1060,13 +1199,28 @@ async fn process_inbound( return Ok(false); } } + Ok(unclaimed(message)) +} +/// [`Family::RoomsReply`]. +fn rooms_reply( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let pre = cx.pre.take(); + // Computed at most once per message (see `community_reply_proven`). + let mut reply_proven: Option = None; + let InboundEffects { rooms_replies, .. } = effects; // Data-room replies (vtc/rooms/*), in either carriage, for the Rooms panel — keyed by // `threadId` and correlated there against the one request the panel has outstanding. // A `trust-task-error` is offered here as well and falls through. - if openvtc_core::community_rooms::is_reply_type(&message.typ) - && let Some((_, doc)) = openvtc_core::capabilities::parse_envelope_document(&message.body) - && let Some((thid, reply)) = openvtc_core::community_rooms::parse_reply(&doc) + if crate::community_rooms::is_reply_type(&message.typ) + && let Some((_, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some((thid, reply)) = crate::community_rooms::parse_reply(&doc) && community_reply_proven( config, &pre, @@ -1076,7 +1230,7 @@ async fn process_inbound( &mut reply_proven, ) { - rooms_replies.push(crate::state_handler::rooms_actions::InboundReply { + rooms_replies.push(ThreadedReply { from: from_did.to_string(), issuer: doc.issuer.clone(), thid, @@ -1086,13 +1240,30 @@ async fn process_inbound( return Ok(false); } } + Ok(unclaimed(message)) +} +/// [`Family::ElectionsReply`]. +fn elections_reply( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let pre = cx.pre.take(); + // Computed at most once per message (see `community_reply_proven`). + let mut reply_proven: Option = None; + let InboundEffects { + elections_replies, .. + } = effects; // Election replies (vtc/elections/*), in either carriage, for the Elections panel — keyed by // `threadId` and correlated there against the one request the panel has outstanding. A // `trust-task-error` is offered here as well and falls through. - if openvtc_core::elections::is_reply_type(&message.typ) - && let Some((_, doc)) = openvtc_core::capabilities::parse_envelope_document(&message.body) - && let Some((thid, reply)) = openvtc_core::elections::parse_reply(&doc) + if crate::elections::is_reply_type(&message.typ) + && let Some((_, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some((thid, reply)) = crate::elections::parse_reply(&doc) && community_reply_proven( config, &pre, @@ -1102,7 +1273,7 @@ async fn process_inbound( &mut reply_proven, ) { - elections_replies.push(crate::state_handler::elections_actions::InboundReply { + elections_replies.push(ThreadedReply { from: from_did.to_string(), issuer: doc.issuer.clone(), thid, @@ -1112,18 +1283,26 @@ async fn process_inbound( return Ok(false); } } + Ok(unclaimed(message)) +} +/// [`Family::WitnessReply`]. Without a `threadId` it answers nothing. +fn witness_reply(cx: &mut Cx<'_>, effects: &mut InboundEffects) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let InboundEffects { + witness_replies, .. + } = effects; // A witness's answers (`witness/session/0.2#response`, `witness/session/submit/0.2#response`), // for a witnessing of one of our relationships. They arrive on the relationship DID's own // listener, in either carriage, threaded on the session id. Nothing is read here: the // witnessing waiting on that thread verifies each as the witness's first. A // `trust-task-error` may be a witness's refusal as much as anything else's, so it is offered // here too and falls through, as it does for the capability and git-ns views above. - if (openvtc_core::witness::is_reply_type(&message.typ) - || is_trust_task_error_type(&message.typ)) + if (crate::witness::is_reply_type(&message.typ) || is_trust_task_error_type(&message.typ)) && message.body.get("threadId").is_some() { - witness_replies.push(openvtc_core::witness::InboundReply { + witness_replies.push(crate::witness::InboundReply { sender: from_did.to_string(), document: message.body.clone(), }); @@ -1131,16 +1310,36 @@ async fn process_inbound( return Ok(false); } } + Ok(unclaimed(message)) +} +/// [`Family::Vetting`]. +async fn vetting( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let tdk = cx.tdk; + let service = cx.service; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let recipient_persona = cx.recipient_persona; + let pre = cx.pre.take(); + let InboundEffects { + vetting_answers, + vetting_grant_checks, + .. + } = effects; // Peer identity vetting (docs/design/vetting-process.md): requests, // sessions, cards, statements, declines and refusals between members, and a // community's vetting requirements. Tried before the join and credential // routes below because it shares two of their types; `handle` returns // `None` for anything that is not vetting's, which falls through unchanged. - if openvtc_core::vetting::inbound::may_claim(&message.typ) { + if crate::vetting::inbound::may_claim(&message.typ) { let resolver = vta_sdk::trust_task_proof::TrustTaskVmResolver::new(tdk.did_resolver().clone()); - let ctx = openvtc_core::vetting::inbound::Context { + let ctx = crate::vetting::inbound::Context { account: &config.account, resolver: &resolver, did_resolver: tdk.did_resolver(), @@ -1157,7 +1356,7 @@ async fn process_inbound( recipient: recipient_persona.map(|p| (p, recipient_did.as_str())), now: chrono::Utc::now(), }; - if let Some(handled) = openvtc_core::vetting::inbound::handle( + if let Some(handled) = crate::vetting::inbound::handle( &mut config.private.vetting, &ctx, &mut config.private.seen_documents, @@ -1167,8 +1366,7 @@ async fn process_inbound( .await { if let Some(reply) = handled.reply - && let Err(e) = - openvtc_core::vetting::wire::send_reply(config, tdk, service, reply).await + && let Err(e) = crate::vetting::wire::send_reply(config, tdk, service, reply).await { warn!(to = %from_did, error = %e, "could not send vetting reply"); } @@ -1194,7 +1392,14 @@ async fn process_inbound( return Ok(handled.changed || noticed); } } + Ok(unclaimed(message)) +} +/// [`Family::JoinSubmitReceipt`]. +fn join_submit_receipt(config: &mut Config, cx: &mut Cx<'_>) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let pre = cx.pre.take(); // VTC join-requests submit-receipt: the VTC's asynchronous reply to our // submit, threaded (`thid`) on our submit message id. It carries the // authoritative VTC `requestId`; reconcile it onto the matching Pending @@ -1217,6 +1422,89 @@ async fn process_inbound( } return Ok(changed); } + Ok(unclaimed(message)) +} + +/// [`Family::CredentialIssue`]: vetting's first (a vetter's statement or grant +/// arrives this way), then the community's credential. +async fn credential_issue( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let tdk = cx.tdk; + let service = cx.service; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let recipient_persona = cx.recipient_persona; + let pre = cx.pre.take(); + let InboundEffects { + vetting_answers, + vetting_grant_checks, + .. + } = effects; + // Peer identity vetting (docs/design/vetting-process.md): requests, + // sessions, cards, statements, declines and refusals between members, and a + // community's vetting requirements. Tried before the join and credential + // routes below because it shares two of their types; `handle` returns + // `None` for anything that is not vetting's, which falls through unchanged. + if crate::vetting::inbound::may_claim(&message.typ) { + let resolver = + vta_sdk::trust_task_proof::TrustTaskVmResolver::new(tdk.did_resolver().clone()); + let ctx = crate::vetting::inbound::Context { + account: &config.account, + resolver: &resolver, + did_resolver: tdk.did_resolver(), + issued_credential: match &pre { + Some(PreVerified::Credential(r)) => Some(r), + _ => None, + }, + // Always `Some`: an answer is taken only as checked off the loop, + // never resolved here. + community_answer: Some(match &pre { + Some(PreVerified::Operational(r)) => r, + _ => &NOT_CHECKED, + }), + recipient: recipient_persona.map(|p| (p, recipient_did.as_str())), + now: chrono::Utc::now(), + }; + if let Some(handled) = crate::vetting::inbound::handle( + &mut config.private.vetting, + &ctx, + &mut config.private.seen_documents, + message, + &from_did, + ) + .await + { + if let Some(reply) = handled.reply + && let Err(e) = crate::vetting::wire::send_reply(config, tdk, service, reply).await + { + warn!(to = %from_did, error = %e, "could not send vetting reply"); + } + if let Some(answer) = handled.answer { + vetting_answers.push(answer); + } + if let Some(check) = handled.grant_check { + vetting_grant_checks.push(check); + } + let noticed = handled.notice.is_some(); + if let Some(notice) = handled.notice { + config + .private + .logs + .insert(LogFamily::Community, notice.describe()); + if let Some((id, task)) = notice.task() { + config + .private + .tasks + .new_task_for(&Arc::new(id), task, recipient_persona); + } + } + return Ok(handled.changed || noticed); + } + } // VTC credential delivery: on approve, the VTC pushes the issued VMC + role // VEC as separate `credential-exchange/issue` messages. Store each on the @@ -1250,7 +1538,7 @@ async fn process_inbound( let verified = match match pre { Some(PreVerified::Credential(Ok(v))) if *v.value() == credential => Ok(v), Some(PreVerified::Credential(Err(e))) => Err(e), - _ => Err(openvtc_core::issued_credential::IssuedCredentialError::NotChecked), + _ => Err(crate::issued_credential::IssuedCredentialError::NotChecked), } { Ok(verified) => verified, Err(e) => { @@ -1291,7 +1579,9 @@ async fn process_inbound( // re-send it without minting a different credential. if let Some(record) = config.account.membership_mut(&from_did, persona_id) { record.member_vmc = Some(vmc); - record.clear_retired(openvtc_core::config::account::RetiredCredential::MEMBER_ACKNOWLEDGEMENT); + record.clear_retired( + crate::config::account::RetiredCredential::MEMBER_ACKNOWLEDGEMENT, + ); } info!( vtc = %from_did, @@ -1326,7 +1616,19 @@ async fn process_inbound( } return Ok(changed); } + Ok(unclaimed(message)) +} +/// [`Family::JoinVerdict`]. +fn join_verdict( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let pre = cx.pre.take(); + let InboundEffects { inactivated, .. } = effects; // VTC join-requests submit `#response`: the synchronous admission verdict in // the trust-task join model (allow / deny / refer / request_more), threaded // (`thid`) on our submit message id. `allow` → Active, `deny` → Rejected; a @@ -1342,7 +1644,7 @@ async fn process_inbound( let outcome = handle_join_verdict(&mut config.account, message, &from_did); // A `requestMore` is the one verdict that asks the member to act, so // what it lacks is said where they will see it — not only in a trace. - if let Some(needs) = openvtc_core::messaging::join_verdict_needs(message) { + if let Some(needs) = crate::messaging::join_verdict_needs(message) { // Under a hidden-vetting criterion the join carried a proof, not statements, and // the sentence is about the proof. let hidden = config @@ -1355,7 +1657,7 @@ async fn process_inbound( LogFamily::Community, format!( "Community ({from_did}) has not decided your join yet: {}.", - openvtc_core::messaging::describe_join_needs(&needs, hidden) + crate::messaging::describe_join_needs(&needs, hidden) ), ); } @@ -1367,40 +1669,135 @@ async fn process_inbound( } return Ok(outcome.changed); } + Ok(unclaimed(message)) +} - // DIDComm problem-report. It carries no proof, so it changes nothing — a - // join is rejected only by the community's signed `trust-task-error` or - // verdict. A report from a community we hold a record with is surfaced so a - // refusal is not invisible; one from anyone else is dropped without a trace. - if message.typ == PROBLEM_REPORT_TYPE { - let Some(note) = read_problem_report(&config.account, message, &from_did) else { - debug!("problem-report from a party we hold no membership with — ignored"); - return Ok(false); - }; - warn!( - code = %note.code, - on_pending_join = note.on_pending_join, - "community reported a problem (unsigned — not acted on)" - ); - config.private.logs.insert( - LogFamily::Community, - format!( - "Community ({from_did}) reported a problem [{}]: {} — unsigned, so nothing \ - was changed", - note.code, note.comment - ), - ); - return Ok(true); - } - - // The community's answer to a renewal we asked for - // (`vtc/members/renew/0.1#response`): the re-issued VMC and role VAC, - // verified off the loop as the community's signed document and as two - // issued credentials. Stored on the membership, replacing what it held — - // which is what clears a pre-v1 notice — and then answered with a fresh - // acknowledgement, because the new grant has a new digest. - if message.typ == openvtc_core::renewal::MEMBERS_RENEW_RESPONSE_TYPE { - use openvtc_core::renewal::{RenewalError, RenewalOutcome}; +/// [`Family::ProblemReport`]: vetting's first, then a community's report. +async fn problem_report( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let tdk = cx.tdk; + let service = cx.service; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let recipient_persona = cx.recipient_persona; + let pre = cx.pre.take(); + let InboundEffects { + vetting_answers, + vetting_grant_checks, + .. + } = effects; + // Peer identity vetting (docs/design/vetting-process.md): requests, + // sessions, cards, statements, declines and refusals between members, and a + // community's vetting requirements. Tried before the join and credential + // routes below because it shares two of their types; `handle` returns + // `None` for anything that is not vetting's, which falls through unchanged. + if crate::vetting::inbound::may_claim(&message.typ) { + let resolver = + vta_sdk::trust_task_proof::TrustTaskVmResolver::new(tdk.did_resolver().clone()); + let ctx = crate::vetting::inbound::Context { + account: &config.account, + resolver: &resolver, + did_resolver: tdk.did_resolver(), + issued_credential: match &pre { + Some(PreVerified::Credential(r)) => Some(r), + _ => None, + }, + // Always `Some`: an answer is taken only as checked off the loop, + // never resolved here. + community_answer: Some(match &pre { + Some(PreVerified::Operational(r)) => r, + _ => &NOT_CHECKED, + }), + recipient: recipient_persona.map(|p| (p, recipient_did.as_str())), + now: chrono::Utc::now(), + }; + if let Some(handled) = crate::vetting::inbound::handle( + &mut config.private.vetting, + &ctx, + &mut config.private.seen_documents, + message, + &from_did, + ) + .await + { + if let Some(reply) = handled.reply + && let Err(e) = crate::vetting::wire::send_reply(config, tdk, service, reply).await + { + warn!(to = %from_did, error = %e, "could not send vetting reply"); + } + if let Some(answer) = handled.answer { + vetting_answers.push(answer); + } + if let Some(check) = handled.grant_check { + vetting_grant_checks.push(check); + } + let noticed = handled.notice.is_some(); + if let Some(notice) = handled.notice { + config + .private + .logs + .insert(LogFamily::Community, notice.describe()); + if let Some((id, task)) = notice.task() { + config + .private + .tasks + .new_task_for(&Arc::new(id), task, recipient_persona); + } + } + return Ok(handled.changed || noticed); + } + } + + // DIDComm problem-report. It carries no proof, so it changes nothing — a + // join is rejected only by the community's signed `trust-task-error` or + // verdict. A report from a community we hold a record with is surfaced so a + // refusal is not invisible; one from anyone else is dropped without a trace. + if message.typ == PROBLEM_REPORT_TYPE { + let Some(note) = read_problem_report(&config.account, message, &from_did) else { + debug!("problem-report from a party we hold no membership with — ignored"); + return Ok(false); + }; + warn!( + code = %note.code, + on_pending_join = note.on_pending_join, + "community reported a problem (unsigned — not acted on)" + ); + config.private.logs.insert( + LogFamily::Community, + format!( + "Community ({from_did}) reported a problem [{}]: {} — unsigned, so nothing \ + was changed", + note.code, note.comment + ), + ); + return Ok(true); + } + Ok(unclaimed(message)) +} + +/// [`Family::RenewalResponse`]. +async fn renewal_response( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let tdk = cx.tdk; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let pre = cx.pre.take(); + let InboundEffects { renewals, .. } = effects; + // The community's answer to a renewal we asked for + // (`vtc/members/renew/0.1#response`): the re-issued VMC and role VAC, + // verified off the loop as the community's signed document and as two + // issued credentials. Stored on the membership, replacing what it held — + // which is what clears a pre-v1 notice — and then answered with a fresh + // acknowledgement, because the new grant has a new digest. + if message.typ == crate::renewal::MEMBERS_RENEW_RESPONSE_TYPE { + use crate::renewal::{RenewalError, RenewalOutcome}; let thid = message.thid.as_deref().unwrap_or_default(); let verified = match &pre { Some(PreVerified::Renewal(Ok(v))) => v.clone(), @@ -1414,7 +1811,7 @@ async fn process_inbound( // else answers nothing the member is waiting on. The request is // not consumed — a forged reply must not be able to cancel the // genuine one still on its way. - if openvtc_core::renewal::is_pending(&from_did, thid) { + if crate::renewal::is_pending(&from_did, thid) { config .account .note_credential_refused(&from_did, Some(&e.to_string())); @@ -1437,7 +1834,7 @@ async fn process_inbound( warn!(vtc = %from_did, reason = %e, "renewal reply refused"); return Ok(false); } - match openvtc_core::renewal::take_pending(&from_did, thid) { + match crate::renewal::take_pending(&from_did, thid) { Some(member) if member == verified.member_did() => {} _ => { warn!(vtc = %from_did, "renewal reply answers no renewal of ours — ignored"); @@ -1445,58 +1842,58 @@ async fn process_inbound( } } commit_community_document(config, verified.document().clone()); - let outcome = - match openvtc_core::renewal::apply_renewal(&mut config.account, &verified, &from_did) { - Err(e) => RenewalOutcome::Failed(e.to_string()), - Ok(summary) => { - config.account.note_credential_refused(&from_did, None); - // The new grant has a new digest, so the community is owed a - // fresh acknowledgement — sent the way the join flow sends one - // on admission. Best-effort: the renewed credentials are - // already stored, and the member can send it with `m`. - // - // A renewal that rescued an approved join closes its - // request, as the delivered credential would have. - let acknowledged = match issue_member_vmc_for( - config, - tdk, - &from_did, - summary.persona, - summary.closed_join, - ) - .await - { - Ok((_, vmc)) => { - openvtc_core::renewal::store_acknowledgement( - &mut config.account, - &from_did, - summary.persona, - vmc, - ); - Ok(()) - } - Err(e) => { - warn!( - vtc = %from_did, - error = %e, - "membership renewed, but our acknowledgement could not be sent" - ); - Err(e.to_string()) - } - }; - info!( - vtc = %from_did, - personhood = summary.personhood, - personhood_changed = summary.personhood_changed, - cleared_retired = summary.cleared_retired, - "membership renewed" - ); - RenewalOutcome::Renewed { - summary, - acknowledged, + let outcome = match crate::renewal::apply_renewal(&mut config.account, &verified, &from_did) + { + Err(e) => RenewalOutcome::Failed(e.to_string()), + Ok(summary) => { + config.account.note_credential_refused(&from_did, None); + // The new grant has a new digest, so the community is owed a + // fresh acknowledgement — sent the way the join flow sends one + // on admission. Best-effort: the renewed credentials are + // already stored, and the member can send it with `m`. + // + // A renewal that rescued an approved join closes its + // request, as the delivered credential would have. + let acknowledged = match issue_member_vmc_for( + config, + tdk, + &from_did, + summary.persona, + summary.closed_join, + ) + .await + { + Ok((_, vmc)) => { + crate::renewal::store_acknowledgement( + &mut config.account, + &from_did, + summary.persona, + vmc, + ); + Ok(()) } + Err(e) => { + warn!( + vtc = %from_did, + error = %e, + "membership renewed, but our acknowledgement could not be sent" + ); + Err(e.to_string()) + } + }; + info!( + vtc = %from_did, + personhood = summary.personhood, + personhood_changed = summary.personhood_changed, + cleared_retired = summary.cleared_retired, + "membership renewed" + ); + RenewalOutcome::Renewed { + summary, + acknowledged, } - }; + } + }; config.private.logs.insert( LogFamily::Community, format!("Community ({from_did}): {}", outcome.describe()), @@ -1504,103 +1901,340 @@ async fn process_inbound( renewals.push((from_did.to_string(), outcome)); return Ok(true); } + Ok(unclaimed(message)) +} - // A refusal of a renewal we asked for — `vtc/members/renew:notMember`, or - // anything else the community answers it with. Matched on the thread - // before the join handler below, which would otherwise take it for a - // refusal of a join and drop it for matching no pending request. - if is_trust_task_error_type(&message.typ) - && let Some(thid) = message.thid.as_deref() - && openvtc_core::renewal::is_pending(&from_did, thid) - { - if !community_reply_proven( +/// [`Family::TrustTaskError`]: a refusal may answer any request we sent, so it +/// is offered to each view waiting on a thread (capability, `git-ns`, rooms, +/// elections, a witnessing), then to vetting, then to a renewal of ours, and +/// last to the join it may refuse. Each step drops a refusal that threads on +/// nothing of its own. The order is the order these were checked in before the +/// registry, and the join step always decides. +async fn trust_task_error( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let tdk = cx.tdk; + let service = cx.service; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let recipient_persona = cx.recipient_persona; + let pre = cx.pre.take(); + // Computed at most once per message (see `community_reply_proven`). + let mut reply_proven: Option = None; + let InboundEffects { + inactivated, + capability_replies, + git_ns_replies, + rooms_replies, + elections_replies, + vetting_answers, + vetting_grant_checks, + renewals, + witness_replies, + .. + } = effects; + // Capability replies (governance/capability/*), in either carriage: hand + // them to the state loop keyed by the document's `threadId` (== our request + // id). This is a fan-in point waiting on nothing in particular, so the + // document is classified against its own `threadId`; the correlation that + // matters is `apply_capability_replies` matching it to the open view's + // `pending_thid`, and an uncorrelated reply is dropped there. + // + // A `trust-task-error` is ambiguous here — it may refuse a capability + // toggle or anything else we asked — so it is offered to the capability + // view *and* falls through to the handlers below; each side drops an error + // that threads on nothing of its own. + if is_capability_reply_type(&message.typ) + && let Some((thid, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some(reply) = crate::capabilities::parse_capability_reply(&doc, &thid) + && community_reply_proven( config, &pre, message, &from_did, &recipient_did, &mut reply_proven, - ) { + ) + { + capability_replies.push((from_did.to_string(), thid, reply)); + if !is_trust_task_error_type(&message.typ) { return Ok(false); } - let _ = openvtc_core::renewal::take_pending(&from_did, thid); - let outcome = openvtc_core::renewal::RenewalOutcome::Refused( - openvtc_core::renewal::RenewalRefusal::read(&message.body), - ); - warn!(vtc = %from_did, "renewal refused by the community"); - config.private.logs.insert( - LogFamily::Community, - format!("Community ({from_did}): {}", outcome.describe()), - ); - renewals.push((from_did.to_string(), outcome)); - return Ok(true); } - // VTC trust-task-error: the framework failure document for a Trust Task join - // ceremony (malformed / denied / internal), threaded on our submit id. Trust - // Tasks signal failures with these documents, NOT DIDComm problem-reports — - // without this branch a failed ceremony was an unknown type, silently dropped - // into a stuck `Pending`. Matched by type prefix (version-agnostic). - if is_trust_task_error_type(&message.typ) { - // A refusal that rejects a join is taken only from the community's - // signed document; an unsigned one is ignored with a log line. - // - // Every refusal is first offered to the capability and git-ns views - // above, which prove it and record it as acted on. Checked again here - // it would read as a replay of itself — and warn that a community - // refusal was ignored, when it was the answer a view was waiting for. - // Proven in this pass, it needs no second check; and unless it refuses - // a join of ours it is none of this handler's business, so it is let - // go quietly rather than reported as matching nothing. - let verified = if reply_proven == Some(true) { - let refuses_a_join = message - .thid - .as_deref() - .and_then(|thid| uuid::Uuid::parse_str(thid).ok()) - .is_some_and(|id| { - config - .account - .membership_by_pending_request(&from_did, id) - .is_some() - }); - if !refuses_a_join { - debug!( - vtc = %from_did, - "community refusal for a capability or git-ns request — not a join's" - ); - return Ok(false); - } - None - } else { - match community_document(config, &pre, &from_did) { - Ok(v) => Some(v), - Err(e) => { - warn!(reason = %e, "unverified community refusal ignored"); - return Ok(false); - } - } - }; - let outcome = handle_join_trust_task_error(&mut config.account, message, &from_did); - if outcome.changed - && let Some(verified) = verified - { - commit_community_document(config, verified); - } - if let Some(persona) = outcome.inactivated { - inactivated.push((from_did.to_string(), persona)); + // Git namespace replies (git-ns/*), in either carriage, for the Repos + // panel — keyed by the document's `threadId` and correlated there against + // the one request the panel has outstanding; anything else is dropped. + // A `trust-task-error` is offered here as well and falls through, for the + // same reason as above. + if crate::git_ns::is_reply_type(&message.typ) + && let Some((_, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some((thid, reply)) = crate::git_ns::parse_reply(&doc) + && community_reply_proven( + config, + &pre, + message, + &from_did, + &recipient_did, + &mut reply_proven, + ) + { + // The sender and the document's issuer travel with the answer: the + // Repos view takes one only from the community it asked. + git_ns_replies.push(ThreadedReply { + from: from_did.to_string(), + issuer: doc.issuer.clone(), + thid, + reply, + }); + if !is_trust_task_error_type(&message.typ) { + return Ok(false); } - return Ok(outcome.changed); } - // VTC join-requests status-response: the authoritative lifecycle resolution - // (approved / rejected / deferred) for a Pending join, correlated by - // `requestId` (R-B-8). A rejection inactivates the community, so report its - // VTC DID up so the loop deregisters the session (R-S-3). - if message.typ == JOIN_REQUEST_STATUS_RESPONSE_TYPE { - let verified = match community_document(config, &pre, &from_did) { - Ok(v) => v, - Err(e) => { - warn!(reason = %e, "join status response refused"); + // Data-room replies (vtc/rooms/*), in either carriage, for the Rooms panel — keyed by + // `threadId` and correlated there against the one request the panel has outstanding. + // A `trust-task-error` is offered here as well and falls through. + if crate::community_rooms::is_reply_type(&message.typ) + && let Some((_, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some((thid, reply)) = crate::community_rooms::parse_reply(&doc) + && community_reply_proven( + config, + &pre, + message, + &from_did, + &recipient_did, + &mut reply_proven, + ) + { + rooms_replies.push(ThreadedReply { + from: from_did.to_string(), + issuer: doc.issuer.clone(), + thid, + reply, + }); + if !is_trust_task_error_type(&message.typ) { + return Ok(false); + } + } + + // Election replies (vtc/elections/*), in either carriage, for the Elections panel — keyed by + // `threadId` and correlated there against the one request the panel has outstanding. A + // `trust-task-error` is offered here as well and falls through. + if crate::elections::is_reply_type(&message.typ) + && let Some((_, doc)) = crate::capabilities::parse_envelope_document(&message.body) + && let Some((thid, reply)) = crate::elections::parse_reply(&doc) + && community_reply_proven( + config, + &pre, + message, + &from_did, + &recipient_did, + &mut reply_proven, + ) + { + elections_replies.push(ThreadedReply { + from: from_did.to_string(), + issuer: doc.issuer.clone(), + thid, + reply, + }); + if !is_trust_task_error_type(&message.typ) { + return Ok(false); + } + } + + // A witness's answers (`witness/session/0.2#response`, `witness/session/submit/0.2#response`), + // for a witnessing of one of our relationships. They arrive on the relationship DID's own + // listener, in either carriage, threaded on the session id. Nothing is read here: the + // witnessing waiting on that thread verifies each as the witness's first. A + // `trust-task-error` may be a witness's refusal as much as anything else's, so it is offered + // here too and falls through, as it does for the capability and git-ns views above. + if (crate::witness::is_reply_type(&message.typ) || is_trust_task_error_type(&message.typ)) + && message.body.get("threadId").is_some() + { + witness_replies.push(crate::witness::InboundReply { + sender: from_did.to_string(), + document: message.body.clone(), + }); + if !is_trust_task_error_type(&message.typ) { + return Ok(false); + } + } + + // Peer identity vetting (docs/design/vetting-process.md): requests, + // sessions, cards, statements, declines and refusals between members, and a + // community's vetting requirements. Tried before the join and credential + // routes below because it shares two of their types; `handle` returns + // `None` for anything that is not vetting's, which falls through unchanged. + if crate::vetting::inbound::may_claim(&message.typ) { + let resolver = + vta_sdk::trust_task_proof::TrustTaskVmResolver::new(tdk.did_resolver().clone()); + let ctx = crate::vetting::inbound::Context { + account: &config.account, + resolver: &resolver, + did_resolver: tdk.did_resolver(), + issued_credential: match &pre { + Some(PreVerified::Credential(r)) => Some(r), + _ => None, + }, + // Always `Some`: an answer is taken only as checked off the loop, + // never resolved here. + community_answer: Some(match &pre { + Some(PreVerified::Operational(r)) => r, + _ => &NOT_CHECKED, + }), + recipient: recipient_persona.map(|p| (p, recipient_did.as_str())), + now: chrono::Utc::now(), + }; + if let Some(handled) = crate::vetting::inbound::handle( + &mut config.private.vetting, + &ctx, + &mut config.private.seen_documents, + message, + &from_did, + ) + .await + { + if let Some(reply) = handled.reply + && let Err(e) = crate::vetting::wire::send_reply(config, tdk, service, reply).await + { + warn!(to = %from_did, error = %e, "could not send vetting reply"); + } + if let Some(answer) = handled.answer { + vetting_answers.push(answer); + } + if let Some(check) = handled.grant_check { + vetting_grant_checks.push(check); + } + let noticed = handled.notice.is_some(); + if let Some(notice) = handled.notice { + config + .private + .logs + .insert(LogFamily::Community, notice.describe()); + if let Some((id, task)) = notice.task() { + config + .private + .tasks + .new_task_for(&Arc::new(id), task, recipient_persona); + } + } + return Ok(handled.changed || noticed); + } + } + + // A refusal of a renewal we asked for — `vtc/members/renew:notMember`, or + // anything else the community answers it with. Matched on the thread + // before the join handler below, which would otherwise take it for a + // refusal of a join and drop it for matching no pending request. + if is_trust_task_error_type(&message.typ) + && let Some(thid) = message.thid.as_deref() + && crate::renewal::is_pending(&from_did, thid) + { + if !community_reply_proven( + config, + &pre, + message, + &from_did, + &recipient_did, + &mut reply_proven, + ) { + return Ok(false); + } + let _ = crate::renewal::take_pending(&from_did, thid); + let outcome = crate::renewal::RenewalOutcome::Refused( + crate::renewal::RenewalRefusal::read(&message.body), + ); + warn!(vtc = %from_did, "renewal refused by the community"); + config.private.logs.insert( + LogFamily::Community, + format!("Community ({from_did}): {}", outcome.describe()), + ); + renewals.push((from_did.to_string(), outcome)); + return Ok(true); + } + + // VTC trust-task-error: the framework failure document for a Trust Task join + // ceremony (malformed / denied / internal), threaded on our submit id. Trust + // Tasks signal failures with these documents, NOT DIDComm problem-reports — + // without this branch a failed ceremony was an unknown type, silently dropped + // into a stuck `Pending`. Matched by type prefix (version-agnostic). + if is_trust_task_error_type(&message.typ) { + // A refusal that rejects a join is taken only from the community's + // signed document; an unsigned one is ignored with a log line. + // + // Every refusal is first offered to the capability and git-ns views + // above, which prove it and record it as acted on. Checked again here + // it would read as a replay of itself — and warn that a community + // refusal was ignored, when it was the answer a view was waiting for. + // Proven in this pass, it needs no second check; and unless it refuses + // a join of ours it is none of this handler's business, so it is let + // go quietly rather than reported as matching nothing. + let verified = if reply_proven == Some(true) { + let refuses_a_join = message + .thid + .as_deref() + .and_then(|thid| uuid::Uuid::parse_str(thid).ok()) + .is_some_and(|id| { + config + .account + .membership_by_pending_request(&from_did, id) + .is_some() + }); + if !refuses_a_join { + debug!( + vtc = %from_did, + "community refusal for a capability or git-ns request — not a join's" + ); + return Ok(false); + } + None + } else { + match community_document(config, &pre, &from_did) { + Ok(v) => Some(v), + Err(e) => { + warn!(reason = %e, "unverified community refusal ignored"); + return Ok(false); + } + } + }; + let outcome = handle_join_trust_task_error(&mut config.account, message, &from_did); + if outcome.changed + && let Some(verified) = verified + { + commit_community_document(config, verified); + } + if let Some(persona) = outcome.inactivated { + inactivated.push((from_did.to_string(), persona)); + } + return Ok(outcome.changed); + } + Ok(unclaimed(message)) +} + +/// [`Family::JoinStatusResponse`]. +fn join_status_response( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let pre = cx.pre.take(); + let InboundEffects { inactivated, .. } = effects; + // VTC join-requests status-response: the authoritative lifecycle resolution + // (approved / rejected / deferred) for a Pending join, correlated by + // `requestId` (R-B-8). A rejection inactivates the community, so report its + // VTC DID up so the loop deregisters the session (R-S-3). + if message.typ == JOIN_REQUEST_STATUS_RESPONSE_TYPE { + let verified = match community_document(config, &pre, &from_did) { + Ok(v) => v, + Err(e) => { + warn!(reason = %e, "join status response refused"); return Ok(false); } }; @@ -1613,7 +2247,14 @@ async fn process_inbound( } return Ok(outcome.changed); } + Ok(unclaimed(message)) +} +/// [`Family::CommunityProfileResponse`]. +fn community_profile_response(config: &mut Config, cx: &mut Cx<'_>) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let pre = cx.pre.take(); // VTC community-profile response: carries the community's declared // `relationshipIdentifierDefault`, which seeds the pairwise-vs-attributed // default of the new-relationship form (issue #241). Informational — it @@ -1630,7 +2271,7 @@ async fn process_inbound( let asked = message .thid .as_deref() - .is_some_and(|thid| openvtc_core::join::take_profile_query(&from_did, thid)); + .is_some_and(|thid| crate::join::take_profile_query(&from_did, thid)); if !asked { warn!("community profile response we did not ask for — ignored"); return Ok(false); @@ -1640,14 +2281,27 @@ async fn process_inbound( handle_community_profile_show_response(&mut config.account, message, &from_did); return Ok(changed); } + Ok(unclaimed(message)) +} +/// [`Family::PersonhoodChallengeResponse`]. +fn personhood_challenge( + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let InboundEffects { + personhood_challenges, + .. + } = effects; // 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) { + match crate::personhood::parse_challenge_reply(&message.body) { Ok(reply) => { debug!( vtc = %from_did, @@ -1664,12 +2318,18 @@ async fn process_inbound( } return Ok(false); } + Ok(unclaimed(message)) +} +/// [`Family::PersonhoodAssertResponse`]. +fn personhood_assert(cx: &mut Cx<'_>) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); // 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) { + match crate::personhood::parse_assert_reply(&message.body) { Ok(reply) => info!( vtc = %from_did, did = %reply.did, @@ -1680,7 +2340,13 @@ async fn process_inbound( } return Ok(false); } + Ok(unclaimed(message)) +} +/// [`Family::MemberVmcResponse`]. +fn member_vmc_response(config: &mut Config, cx: &mut Cx<'_>) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); // 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 { @@ -1694,7 +2360,16 @@ async fn process_inbound( ); return Ok(true); } + Ok(unclaimed(message)) +} +/// [`Family::MemberRequestVmc`]. +async fn member_request_vmc(config: &mut Config, cx: &mut Cx<'_>) -> Result { + let tdk = cx.tdk; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_persona = cx.recipient_persona; + let pre = cx.pre.take(); // VTC → member: "please issue + send your VMC" (`members/request-vmc/0.1`). // Auto-answer: the sender is the community VTC and the recipient is one of our // personas; if we hold an Active membership there, issue + send our reciprocal @@ -1734,7 +2409,9 @@ async fn process_inbound( Ok((_, vmc)) => { if let Some(record) = config.account.membership_mut(&from_did, persona_id) { record.member_vmc = Some(vmc); - record.clear_retired(openvtc_core::config::account::RetiredCredential::MEMBER_ACKNOWLEDGEMENT); + record.clear_retired( + crate::config::account::RetiredCredential::MEMBER_ACKNOWLEDGEMENT, + ); } info!( vtc = %from_did, @@ -1755,7 +2432,19 @@ async fn process_inbound( } return Ok(false); } + Ok(unclaimed(message)) +} +/// [`Family::MemberRemovalNotice`]. +fn member_removal_notice( + config: &mut Config, + cx: &mut Cx<'_>, + effects: &mut InboundEffects, +) -> Result { + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let pre = cx.pre.take(); + let InboundEffects { inactivated, .. } = effects; // VTC → member removal notice (`vtc/members/removal-notice/0.1`): the // community telling a member it removed them (issue #240). Unsolicited — not // threaded on anything we sent — and the first thing that can move a @@ -1763,7 +2452,7 @@ async fn process_inbound( // its VTC DID up for the loop to deregister the session (R-S-3). if message.typ == MEMBER_REMOVAL_NOTICE_TYPE { // Only the community's signature ends a membership. - let notice = match openvtc_core::messaging::bind_removal_notice( + let notice = match crate::messaging::bind_removal_notice( message, &from_did, &config.account, @@ -1787,14 +2476,22 @@ async fn process_inbound( } return Ok(outcome.changed); } + Ok(unclaimed(message)) +} - let msg_type = match MessageType::try_from(message) { - Ok(t) => t, - Err(_) => { - warn!(typ = %message.typ, "unknown message type — ignoring"); - return Ok(false); - } - }; +/// The OpenVTC relationship / VRC / trust-ping families, by their +/// [`MessageType`]. +async fn peer( + config: &mut Config, + cx: &mut Cx<'_>, + msg_type: MessageType, +) -> Result { + let service = cx.service; + let message = cx.message; + let from_did = Arc::clone(&cx.from_did); + let recipient_did = cx.recipient_did.clone(); + let recipient_persona = cx.recipient_persona; + let pre = cx.pre.take(); let thid_display = message.thid.as_deref().unwrap_or("none"); debug!( @@ -1836,7 +2533,7 @@ async fn process_inbound( .map(|rel| Arc::clone(&rel.our_did)) .filter(|our_did| !config.is_persona_did(our_did.as_str())); let listener_to_remove = - our_did.map(|our_did| super::didcomm::listener_id_for_did(&our_did, config)); + our_did.map(|our_did| crate::didcomm::listener_id_for_did(&our_did, config)); if let Some(lid) = listener_to_remove { // Infallible now: dropping a transport closes its socket and // forgets its wire, with nothing left that can fail. @@ -1893,7 +2590,7 @@ async fn process_inbound( // Checked off the loop, before this message was handled at all. if let Err(e) = match &pre { Some(PreVerified::DidBinding(r)) => r.clone(), - _ => Err(openvtc_core::relationships::DidBindingError::NotChecked), + _ => Err(crate::relationships::DidBindingError::NotChecked), } { warn!(reason = %e, "relationship accept refused"); config.private.logs.insert( @@ -1917,7 +2614,7 @@ async fn process_inbound( // If the send fails, still persist the Established state. let finalize_msg = create_finalize_message(&recipient_did, &from_did, &task_id)?; - if let Err(e) = super::didcomm::send_message( + if let Err(e) = crate::didcomm::send_message( service, config, &finalize_msg, @@ -1987,7 +2684,7 @@ async fn process_inbound( // A rejection closes only our own VRC request, on its thread, to the // party it was sent to — the thread id alone names no counterparty, // and anyone who learned it could otherwise delete the task. - if !openvtc_core::tasks::is_our_vrc_request_to( + if !crate::tasks::is_our_vrc_request_to( &config.private.tasks, &config.private.relationships, &task_id, @@ -2015,7 +2712,7 @@ async fn process_inbound( // ===================================================================== MessageType::RelationshipRequest => { let task_id = Arc::new(message.id.clone()); - let body: openvtc_core::relationships::RelationshipRequestBody = + let body: crate::relationships::RelationshipRequestBody = serde_json::from_value(message.body.clone())?; if let Err(e) = validate_did(&body.did) { @@ -2034,7 +2731,7 @@ async fn process_inbound( // Checked off the loop, before this message was handled at all. if let Err(e) = match &pre { Some(PreVerified::DidBinding(r)) => r.clone(), - _ => Err(openvtc_core::relationships::DidBindingError::NotChecked), + _ => Err(crate::relationships::DidBindingError::NotChecked), } { warn!(reason = %e, "relationship request refused"); return Ok(false); @@ -2205,12 +2902,57 @@ async fn process_inbound( debug!(from = %from_did, "trust-ping task created"); Ok(true) } + } +} - _ => { - warn!(msg_type = %message.typ, "unhandled message type"); - Ok(false) +/// The runtime loop's inbound handling without the loop: dispatch, run any +/// deferred check inline, dispatch again with its result. `true` when the +/// config changed. +/// +/// For headless commands that listen as a persona, so a message they were not +/// waiting for is applied rather than lost. Effects that need a live UI or a +/// session manager (a view's reply, a session to tear down) are dropped; the +/// account state they stand beside is applied. +/// +/// `authenticated` is the sender the transport bound to the frame; the +/// message is handled as that sender only (see [`process_inbound_message`]). +pub async fn apply_inline( + config: &mut Config, + tdk: &TDK, + messaging: &Messaging, + seen: &mut SeenMessages, + message: &Message, + authenticated: Option<&str>, +) -> bool { + let nobody = |_: &str| false; + let mut arrival = Arrival::New { waiting: &nobody }; + let mut message = message.clone(); + let mut changed = false; + // Bounded: a returning message is handled, not set aside again. + for _ in 0..3 { + let mut effects = InboundEffects::default(); + match process_inbound_message( + config, + tdk, + messaging, + seen, + &message, + authenticated, + &mut effects, + arrival, + ) + .await + { + Ok(c) => changed |= c, + Err(e) => warn!(error = %e, "inbound message not handled"), } + let Some(deferred) = effects.deferred else { + break; + }; + arrival = Arrival::Returning(deferred.job.run(tdk.clone()).await); + message = deferred.message; } + changed } /// Whether `task_id` is a ping we sent to `from` — directly, or to the @@ -2241,1646 +2983,4 @@ fn is_our_ping_to(config: &Config, task_id: &Arc, from: &Arc) -> } #[cfg(test)] -mod tests { - use super::*; - use crate::state_handler::dispatch_util::{test_config, test_tdk}; - use affinidi_tdk::secrets_resolver::secrets::Secret; - use openvtc_core::config::account::{ - CommunityRecord, CommunityStatus, PersonaId, PersonaRecord, - }; - - const PERSONA: &str = "did:webvh:QmP:example.com:alice"; - - fn community_key() -> (String, Secret) { - let mut s = Secret::generate_ed25519(None, Some(&[0x71; 32])); - let mb = s.get_public_keymultibase().unwrap(); - let did = format!("did:key:{mb}"); - s.id = format!("{did}#{mb}"); - (did, s) - } - - /// A config holding one persona with a Pending join to `vtc`. - fn pending_config(vtc: &str) -> Config { - let mut config = test_config(); - let pid = PersonaId::new(); - config.account.personas.insert( - pid, - PersonaRecord { - extra: serde_json::Map::new(), - persona_id: pid, - did: PERSONA.into(), - did_document: None, - did_locator: None, - key_refs: vec![], - mediator_did: None, - origin_context_id: "openvtc".into(), - created_at: chrono::Utc::now(), - label: None, - }, - ); - config.account.add_membership(CommunityRecord::new_pending( - vtc.into(), - None, - "openvtc/x".into(), - pid, - uuid::Uuid::new_v4(), - chrono::Utc::now(), - )); - config - } - - /// An `issue` as a VTC pushes it: a Trust Task document signed by the - /// community's key, opened out of the binding envelope. - async fn issue(vtc: &str, key: &Secret, credential: serde_json::Value) -> Message { - let mut document = serde_json::from_value(serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": CREDENTIAL_ISSUE_TYPE, - "issuer": vtc, - "recipient": PERSONA, - "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - "payload": { "credential_response": { "credential": credential } }, - })) - .expect("an issue document"); - openvtc_core::capabilities::sign_document(&mut document, key) - .await - .expect("sign the delivery"); - Message::build( - uuid::Uuid::new_v4().to_string(), - CREDENTIAL_ISSUE_TYPE.to_string(), - serde_json::to_value(&document).expect("document json"), - ) - .from(vtc.to_string()) - .to(PERSONA.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize() - } - - async fn membership_vmc(vtc: &str, key: &Secret) -> serde_json::Value { - let mut vc = serde_json::json!({ - "@context": [ - dtg_credentials::W3C_VC_V2_CONTEXT, - dtg_credentials::DTG_CONTEXT_V1 - ], - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"], - "issuer": vtc, - "issuerScope": "public", - "validFrom": "2026-01-01T00:00:00Z", - "validUntil": "2099-01-01T00:00:00Z", - "credentialSubject": { "id": PERSONA }, - }); - let proof = affinidi_data_integrity::DataIntegrityProof::sign( - &vc, - key, - affinidi_data_integrity::SignOptions::new(), - ) - .await - .unwrap(); - vc["proof"] = serde_json::to_value(proof).unwrap(); - vc - } - - fn status(config: &Config, vtc: &str) -> CommunityStatus { - config.account.memberships_for(vtc)[0].status.clone() - } - - /// A credential delivery is not handled on arrival: it is set aside for - /// its check, and nothing — no credential, no activation — is applied - /// until the check's result comes back. Then a verified credential - /// activates the join, and a refused one leaves it Pending. - #[tokio::test] - async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - - // A forged (unsigned) credential: deferred, then refused. - let mut config = pending_config(&vtc); - let mut forged = membership_vmc(&vtc, &key).await; - forged.as_object_mut().unwrap().remove("proof"); - let m = issue(&vtc, &key, forged).await; - let mut effects = InboundEffects::default(); - let changed = process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &m, - m.from.as_deref(), - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - assert!(!changed, "nothing applied on arrival"); - let deferred = effects.deferred.take().expect("set aside for its check"); - assert_eq!(deferred.job.verifying_community(), Some(vtc.as_str())); - assert!(matches!( - status(&config, &vtc), - CommunityStatus::Pending { .. } - )); - let pre = deferred.job.run(tdk.clone()).await; - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &deferred.message, - deferred.message.from.as_deref(), - &mut effects, - Arrival::Returning(pre), - ) - .await - .unwrap(); - assert!( - effects.deferred.is_none(), - "handled on its return, not set aside again" - ); - assert!( - matches!(status(&config, &vtc), CommunityStatus::Pending { .. }), - "refused: still Pending" - ); - assert!( - config.account.memberships_for(&vtc)[0] - .credentials - .is_empty() - ); - - // A genuine credential: deferred, then it activates. - let mut config = pending_config(&vtc); - let genuine = membership_vmc(&vtc, &key).await; - let m = issue(&vtc, &key, genuine).await; - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &m, - m.from.as_deref(), - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - let deferred = effects.deferred.take().expect("set aside"); - assert!( - matches!(status(&config, &vtc), CommunityStatus::Pending { .. }), - "not before its check" - ); - let pre = deferred.job.run(tdk.clone()).await; - assert!(matches!(&pre, PreVerified::Credential(Ok(_)))); - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &deferred.message, - deferred.message.from.as_deref(), - &mut effects, - Arrival::Returning(pre), - ) - .await - .unwrap(); - assert!(status(&config, &vtc).is_active(), "activated once verified"); - } - - /// A community's answer to a hidden-vetting question — here a vetter's enrolment, in the - /// binding envelope the VTC replies in — reaches the vetting router and takes the question - /// it threads on. It used to be dropped before the router saw it, so the question waited - /// out its 30 seconds as "no answer" and the vetter never held a token to attest with. - #[tokio::test] - async fn a_hidden_vetting_answer_reaches_the_question_it_answers() { - use openvtc_core::vetting::queries::{CommunityQuery, QueryKind}; - use openvtc_core::vetting::wire::pcs; - - let (vtc, _key) = community_key(); - let tdk = test_tdk().await; - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - let mut config = pending_config(&vtc); - let persona = *config.account.personas.keys().next().unwrap(); - - for (kind, task, payload) in [ - ( - QueryKind::PcsRoot, - pcs::ROOT_TYPE, - serde_json::json!({ "label": "vetter/2026-10", "preCredential": "z1" }), - ), - ( - QueryKind::PcsTokens, - pcs::TOKENS_TYPE, - serde_json::json!({ "label": "token/2026-10", "tick": 0, "preCredentials": [] }), - ), - ] { - let asked = format!("urn:uuid:{}", uuid::Uuid::new_v4()); - config.private.vetting.ask(CommunityQuery { - document_id: asked.clone(), - community: vtc.clone(), - persona, - kind, - sent_at: chrono::Utc::now(), - }); - let answer = Message::build( - uuid::Uuid::new_v4().to_string(), - openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(), - serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": pcs::response_of(task), - "threadId": asked, - "issuer": vtc, - "recipient": PERSONA, - "payload": payload, - }), - ) - .from(vtc.clone()) - .to(PERSONA.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize(); - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &answer, - answer.from.as_deref(), - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - assert!( - !config - .private - .vetting - .queries - .iter() - .any(|q| q.document_id == asked), - "the {kind:?} answer was heard, so its question no longer waits to time out" - ); - } - } - - /// A result is taken only for the credential it was produced for. - #[tokio::test] - async fn a_check_result_for_another_credential_is_not_taken() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - let mut config = pending_config(&vtc); - - let other = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &other, - other.from.as_deref(), - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - let pre = effects.deferred.take().unwrap().job.run(tdk.clone()).await; - - // A different credential, handed back with the first one's result. - let mut different = membership_vmc(&vtc, &key).await; - different["id"] = serde_json::json!("urn:uuid:different"); - let m = issue(&vtc, &key, different).await; - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &m, - m.from.as_deref(), - &mut effects, - Arrival::Returning(pre), - ) - .await - .unwrap(); - assert!(matches!( - status(&config, &vtc), - CommunityStatus::Pending { .. } - )); - } - - /// Operational documents (here a removal notice) are set aside too, and - /// applied only with their check's result. - #[tokio::test] - async fn a_removal_notice_waits_for_its_check() { - let (vtc, _) = community_key(); - let tdk = test_tdk().await; - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - let mut config = pending_config(&vtc); - let m = Message::build( - uuid::Uuid::new_v4().to_string(), - MEMBER_REMOVAL_NOTICE_TYPE.to_string(), - serde_json::json!({ "payload": { "did": PERSONA } }), - ) - .from(vtc.clone()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize(); - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &m, - m.from.as_deref(), - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - let deferred = effects.deferred.expect("set aside for its check"); - assert!(matches!(deferred.job, VerifyJob::Operational { .. })); - assert_eq!(deferred.job.verifying_community(), None); - } - - /// A config holding one persona with an Active membership of `vtc` whose - /// pre-v1 credentials were set aside on load — what the v1 migration leaves. - /// - /// The persona also has a runtime identity, so a reply addressed to it is - /// recognised as ours (`Config::is_persona_did`), as it is at run time. - fn retired_config(vtc: &str) -> Config { - let mut config = with_identity(pending_config(vtc)); - let record = &mut config.account.communities.get_mut(vtc).unwrap()[0]; - record.activate(chrono::Utc::now()); - for kind in ["Membership", "Role"] { - record - .retired_credentials - .push(openvtc_core::config::account::RetiredCredential { - kind: kind.into(), - credential_id: None, - reason: "pre-v1".into(), - retired_at: chrono::Utc::now(), - }); - } - config - } - - /// `config` with a runtime identity for `PERSONA`, so a reply addressed - /// to it is recognised as ours (`Config::is_persona_did`), as it is at run - /// time. - fn with_identity(mut config: Config) -> Config { - use affinidi_tdk::messaging::profiles::{ATMProfile, ATMProfileInner}; - let pid = config.account.persona_id_for_did(PERSONA).unwrap(); - config.identities.insert( - pid, - openvtc_core::identity::IdentityContext { - persona_id: pid, - did: PERSONA.to_string(), - document: serde_json::from_value(serde_json::json!({ "id": PERSONA })) - .expect("minimal DID document"), - profile: Arc::new(ATMProfile { - inner: Arc::new(ATMProfileInner { - did: PERSONA.to_string(), - alias: PERSONA.to_string(), - mediator: Arc::new(None), - }), - }), - mediator_did: None, - }, - ); - config - } - - /// A Trust Task document from the community to `PERSONA`, threaded on - /// `thid`, signed under `authentication` and carried in the binding - /// envelope — the shape a VTC answers a member verb with. - async fn community_reply( - vtc: &str, - key: &Secret, - typ: &str, - thid: &str, - payload: serde_json::Value, - ) -> Message { - let mut document = serde_json::from_value(serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": typ, - "issuer": vtc, - "recipient": PERSONA, - "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - "threadId": thid, - "payload": payload, - })) - .expect("a reply document"); - openvtc_core::capabilities::sign_document(&mut document, key) - .await - .expect("sign the reply"); - Message::build( - uuid::Uuid::new_v4().to_string(), - openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(), - serde_json::to_value(&document).expect("document json"), - ) - .from(vtc.to_string()) - .to(PERSONA.to_string()) - .thid(thid.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize() - } - - /// A community role VAC conferring `role:member`, signed by `key`. - async fn role_vac(vtc: &str, key: &Secret) -> serde_json::Value { - let vac = DTGCredential::new_community_role_vac( - vtc.to_string(), - PERSONA.to_string(), - "member", - chrono::Utc::now() - chrono::Duration::minutes(1), - chrono::Utc::now() + chrono::Duration::days(365), - ) - .unwrap() - .with_max_attenuation(0) - .unwrap(); - let mut vc = serde_json::to_value(vac).unwrap(); - let proof = affinidi_data_integrity::DataIntegrityProof::sign( - &vc, - key, - affinidi_data_integrity::SignOptions::new(), - ) - .await - .unwrap(); - vc["proof"] = serde_json::to_value(proof).unwrap(); - vc - } - - /// **Renewal, end to end through the dispatcher.** The community's answer - /// to a renewal we asked for is set aside for its check, and only then - /// stored: the re-issued VMC and role VAC replace the set-aside pre-v1 - /// credentials, and the notices go with them. - #[tokio::test] - async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = retired_config(&vtc); - let request = uuid::Uuid::new_v4(); - openvtc_core::renewal::record_request(request, &vtc, PERSONA); - - let vmc = membership_vmc(&vtc, &key).await; - let vac = role_vac(&vtc, &key).await; - let m = community_reply( - &vtc, - &key, - openvtc_core::renewal::MEMBERS_RENEW_RESPONSE_TYPE, - &format!("urn:uuid:{request}"), - serde_json::json!({ - "did": PERSONA, - "vmc": vmc, - "roleVac": vac, - "personhood": false, - "personhoodChanged": false, - }), - ) - .await; - - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - let deferred = effects.deferred.expect("set aside for its check"); - assert!(matches!(deferred.job, VerifyJob::Renewal { .. })); - assert!( - config.account.memberships_for(&vtc)[0] - .credentials - .is_empty(), - "nothing stored before the check" - ); - - let pre = deferred.job.run(tdk.clone()).await; - assert!(matches!(&pre, PreVerified::Renewal(Ok(_)))); - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(pre), - ) - .await; - - let record = &config.account.memberships_for(&vtc)[0]; - assert_eq!( - record - .credentials - .get(&openvtc_core::CredentialKind::Membership), - Some(&vmc) - ); - assert_eq!( - record.credentials.get(&openvtc_core::CredentialKind::Role), - Some(&vac) - ); - assert!(record.retired_credentials.is_empty(), "the notices clear"); - let [(from, outcome)] = effects.renewals.as_slice() else { - panic!("one renewal outcome"); - }; - assert_eq!(from, &vtc); - let openvtc_core::renewal::RenewalOutcome::Renewed { - summary, - acknowledged, - } = outcome - else { - panic!("renewed, got {outcome:?}"); - }; - assert_eq!(summary.roles, vec!["member".to_string()]); - // The test config has no messaging identity, so the acknowledgement - // cannot go out — and the member is told how to send it. - assert!(acknowledged.is_err()); - assert!(outcome.describe().contains("press m")); - - // Answered once: the same reply again is not taken. - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.deferred.is_none()); - assert!(effects.renewals.is_empty()); - } - - /// A reply threading on no renewal of ours is never checked or stored. - #[tokio::test] - async fn a_renewal_reply_we_did_not_ask_for_is_ignored() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = retired_config(&vtc); - let m = community_reply( - &vtc, - &key, - openvtc_core::renewal::MEMBERS_RENEW_RESPONSE_TYPE, - &format!("urn:uuid:{}", uuid::Uuid::new_v4()), - serde_json::json!({ - "did": PERSONA, - "vmc": membership_vmc(&vtc, &key).await, - "roleVac": role_vac(&vtc, &key).await, - "personhood": false, - "personhoodChanged": false, - }), - ) - .await; - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.deferred.is_none()); - assert!(effects.renewals.is_empty()); - assert!( - config.account.memberships_for(&vtc)[0] - .credentials - .is_empty() - ); - } - - /// **notMember.** The community's signed refusal of our renewal reaches - /// the member as such — not as a join refusal, and with nothing changed. - #[tokio::test] - async fn a_renewal_refused_as_not_a_member_is_reported() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = retired_config(&vtc); - let request = uuid::Uuid::new_v4(); - openvtc_core::renewal::record_request(request, &vtc, PERSONA); - let m = community_reply( - &vtc, - &key, - "https://trusttasks.org/spec/trust-task-error/0.1", - &format!("urn:uuid:{request}"), - serde_json::json!({ - "code": openvtc_core::renewal::RENEW_NOT_MEMBER, - "message": "no ACL row — not a member", - "retryable": false, - }), - ) - .await; - - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - let deferred = effects.deferred.expect("set aside for its check"); - let pre = deferred.job.run(tdk.clone()).await; - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(pre), - ) - .await; - - let [(_, outcome)] = effects.renewals.as_slice() else { - panic!("one renewal outcome"); - }; - assert_eq!( - outcome, - &openvtc_core::renewal::RenewalOutcome::Refused( - openvtc_core::renewal::RenewalRefusal::NotMember - ) - ); - let record = &config.account.memberships_for(&vtc)[0]; - assert!(record.status.is_active(), "a refused renewal ends nothing"); - assert_eq!(record.retired_credentials.len(), 2); - assert!(!openvtc_core::renewal::is_pending( - &vtc, - &request.to_string() - )); - } - - /// Collects what is logged at `WARN` and above while it is the default - /// subscriber on this thread. - #[derive(Clone, Default)] - struct Warnings(Arc>>); - - impl std::io::Write for Warnings { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(buf); - Ok(buf.len()) - } - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl Warnings { - fn capture(&self) -> tracing::subscriber::DefaultGuard { - let sink = self.clone(); - tracing::subscriber::set_default( - tracing_subscriber::fmt() - .with_max_level(tracing::Level::WARN) - .with_ansi(false) - .with_writer(move || sink.clone()) - .finish(), - ) - } - fn text(&self) -> String { - String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() - } - } - - /// Dispatch `m` once it has been through its off-loop check, as the loop - /// does: on arrival it is set aside, then handled with the check's result. - async fn dispatch_checked(config: &mut Config, tdk: &TDK, m: &Message) -> InboundEffects { - let effects = dispatch(config, tdk, m, Arrival::FRESH).await; - let deferred = effects.deferred.expect("set aside for its check"); - let pre = deferred.job.run(tdk.clone()).await; - dispatch(config, tdk, &deferred.message, Arrival::Returning(pre)).await - } - - /// A witness's answer reaches the witnessing waiting on it whoever sent it — a witness is - /// no contact and no community — in either carriage, and is not read here: the witnessing - /// verifies it as the witness's. A refusal (`trust-task-error`) is offered too. - #[tokio::test(flavor = "current_thread")] - async fn a_witness_answer_is_handed_to_the_witnessing() { - let tdk = test_tdk().await; - let mut config = test_config(); - let witness = "did:key:z6MkWitness"; - let session = "urn:uuid:9e8d7c6b-0000-4000-8000-0000000000aa"; - for (typ, enveloped) in [ - ( - "https://trusttasks.org/spec/witness/session/0.2#response", - true, - ), - ( - "https://trusttasks.org/spec/witness/session/submit/0.2#response", - false, - ), - ("https://trusttasks.org/spec/trust-task-error/0.5", true), - ] { - let document = serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": typ, - "threadId": session, - "issuer": witness, - "recipient": "did:peer:2.Vz6MkOurs", - "payload": {}, - }); - let message = Message::build( - format!("urn:uuid:{}", uuid::Uuid::new_v4()), - if enveloped { - openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string() - } else { - typ.to_string() - }, - document.clone(), - ) - .from(witness.to_string()) - .to("did:peer:2.Vz6MkOurs".to_string()) - .finalize(); - let effects = dispatch(&mut config, &tdk, &message, Arrival::FRESH).await; - assert!( - effects.deferred.is_none(), - "{typ}: nothing to check off the loop" - ); - let [reply] = effects.witness_replies.as_slice() else { - panic!( - "{typ}: one answer for the witnessing, got {:?}", - effects.witness_replies - ); - }; - assert_eq!(reply.sender, witness); - assert_eq!(reply.document, document); - } - } - - /// A community that offers no capability management refuses the query - /// with a signed `trust-task-error`. It is proven and recorded once, for - /// the capability view, and then falls through the other refusal handlers - /// — which must not re-check it, find it already recorded, and warn that a - /// community refusal was ignored as a replay. - #[tokio::test(flavor = "current_thread")] - async fn a_capability_refusal_is_not_reported_as_its_own_replay() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = retired_config(&vtc); - let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); - let m = community_reply( - &vtc, - &key, - "https://trusttasks.org/spec/trust-task-error/0.1", - &thid, - serde_json::json!({ - "code": "unsupportedType", - "message": "governance/capability/list is not served here", - "retryable": false, - }), - ) - .await; - - let warnings = Warnings::default(); - let effects = { - let _guard = warnings.capture(); - dispatch_checked(&mut config, &tdk, &m).await - }; - - let [(from, reply_thid, reply)] = effects.capability_replies.as_slice() else { - panic!("one capability reply, got {:?}", effects.capability_replies); - }; - assert_eq!(from, &vtc); - assert_eq!(reply_thid, &thid); - assert!(matches!( - reply, - openvtc_core::capabilities::CapabilityReply::Rejected { code, .. } - if code == "unsupportedType" - )); - let logged = warnings.text(); - assert!(!logged.contains("replay"), "{logged}"); - assert!(!logged.contains("refusal ignored"), "{logged}"); - assert!( - !logged.contains("did not match a pending request"), - "{logged}" - ); - assert!( - config.account.memberships_for(&vtc)[0].status.is_active(), - "a capability refusal ends nothing" - ); - - // Replay protection across messages is untouched: the same refusal - // again is not handed to the view a second time. - let effects = dispatch_checked(&mut config, &tdk, &m).await; - assert!(effects.capability_replies.is_empty()); - } - - /// A genuine join refusal still reaches the join handler, though it too is - /// proven first by the capability arm: a `permissionDenied` threaded on - /// our submit rejects the pending join. - #[tokio::test(flavor = "current_thread")] - async fn a_join_refusal_still_rejects_the_pending_join() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = with_identity(pending_config(&vtc)); - let CommunityStatus::Pending { request_id } = status(&config, &vtc) else { - panic!("pending"); - }; - let m = community_reply( - &vtc, - &key, - "https://trusttasks.org/spec/trust-task-error/0.1", - &request_id.to_string(), - serde_json::json!({ - "code": "permissionDenied", - "message": "not admitted", - "retryable": false, - }), - ) - .await; - - let warnings = Warnings::default(); - let effects = { - let _guard = warnings.capture(); - dispatch_checked(&mut config, &tdk, &m).await - }; - - assert!( - status(&config, &vtc) == CommunityStatus::Rejected, - "{:?}", - status(&config, &vtc) - ); - assert_eq!(effects.inactivated.len(), 1); - let logged = warnings.text(); - assert!(!logged.contains("replay"), "{logged}"); - } - - /// A TSP-joined member's Repos view loads from the community's - /// `git-ns/view/0.6` answer, taken the whole way a live one is: sealed in - /// the TSP binding envelope, mapped and passed through the inbound gate - /// (`deliver_tsp_document`), checked off the loop and dispatched - /// (`process_inbound_message`), then correlated by the Repos view - /// (`repos_actions::apply_replies`). The gate used to drop this exact type - /// at debug, and the view timed out on an answer the VTC had sent. - #[tokio::test(flavor = "current_thread")] - async fn a_tsp_git_ns_view_answer_loads_the_repos_view() { - use crate::state_handler::main_page::repos::{Pending, Purpose, ReposPhase, ReposView}; - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = retired_config(&vtc); - let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); - - // The signed document, as the VTC sends it over TSP. - let mut document = serde_json::from_value(serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": "https://trusttasks.org/spec/git-ns/view/0.6#response", - "issuer": vtc, - "recipient": PERSONA, - "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - "threadId": thid, - "payload": { - "accounts": [], - "namespaces": [{"id": "ns_1", "forge": "github.com", "owner": "acme", - "kind": "organization", "mode": "bridge", "state": "bound"}], - "repos": [{"resource": "github.com/acme/gadgets", "forgeId": "1", - "visibility": "public", "state": "active", "owners": [PERSONA], - "bootstrap": {"workflow": true, "keyring": true, - "variables": true, "requiredCheck": true}, - "sync": {"state": "inSync", "drift": []}}], - "rights": [{"subject": PERSONA, "right": "git.repo.own", - "resource": "github.com/acme/gadgets", "grantedBy": PERSONA, - "grantedAt": "2026-09-02T00:00:00Z"}] - }, - })) - .expect("a reply document"); - openvtc_core::capabilities::sign_document(&mut document, &key) - .await - .expect("sign the reply"); - let wrapped = vta_sdk::tsp_binding::wrap_envelope( - &serde_json::to_vec(&document).expect("document bytes"), - ); - let m = openvtc_core::didcomm::deliver_tsp_document(&wrapped, &vtc, PERSONA) - .expect("the answer passes the inbound gate"); - - let effects = dispatch_checked(&mut config, &tdk, &m).await; - assert_eq!(effects.git_ns_replies.len(), 1, "handed to the Repos view"); - - // The Repos view, waiting on the request this answers. - let pid = config.account.persona_id_for_did(PERSONA).unwrap(); - let mut state = crate::state_handler::state::State::default(); - let mut view = ReposView::new(vtc.clone(), pid, PERSONA.into(), "Acme".into()); - view.pending = Some(Pending { - thid: thid.clone(), - sent_at: std::time::Instant::now(), - purpose: Purpose::View, - }); - state.main_page.content_panel.repos.view = Some(view); - - crate::state_handler::repos_actions::apply_replies( - &mut state, - &config, - effects.git_ns_replies, - ); - let view = state.main_page.content_panel.repos.view.as_ref().unwrap(); - assert_eq!(view.phase, ReposPhase::Loaded); - assert!(view.pending.is_none(), "the wait is over"); - assert_eq!(view.my_repos().len(), 1); - } - - /// VTI-NTF-054: a notification addressed to one of our personas is handed over exactly as - /// received, whoever sent it — this client applies no acceptance policy — and one addressed - /// to a DID this account holds no persona for is not. - #[tokio::test] - async fn vti_ntf_054_a_notification_for_a_persona_is_handed_over_as_received() { - let tdk = test_tdk().await; - let mut config = with_identity(pending_config("did:key:z6MkSomeCommunity")); - let stranger = "did:key:z6MkNotACommunityWeKnow"; - let document = serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": openvtc_core::notifications::NOTIFY_TYPE, - "issuer": stranger, - "recipient": PERSONA, - "issuedAt": "2026-11-03T00:00:00.000000Z", - "payload": { "notification": { "notificationId": "n-1" } }, - "proof": { "type": "DataIntegrityProof", "proofValue": "z5opaque" } - }); - let wrapped = vta_sdk::tsp_binding::wrap_envelope(&serde_json::to_vec(&document).unwrap()); - let m = openvtc_core::didcomm::deliver_tsp_document(&wrapped, stranger, PERSONA) - .expect("a notification passes the inbound gate"); - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!( - effects.deferred.is_none(), - "nothing is checked before the hand-over" - ); - assert_eq!(effects.notifications.len(), 1); - let received = &effects.notifications[0]; - assert_eq!(received.document, document, "exactly as received"); - assert_eq!(received.sender, stranger); - assert_eq!(received.addressee, PERSONA); - - let mut elsewhere = document.clone(); - elsewhere["recipient"] = serde_json::json!("did:key:z6MkNotOurs"); - elsewhere["id"] = serde_json::json!(format!("urn:uuid:{}", uuid::Uuid::new_v4())); - let wrapped = vta_sdk::tsp_binding::wrap_envelope(&serde_json::to_vec(&elsewhere).unwrap()); - let m = openvtc_core::didcomm::deliver_tsp_document(&wrapped, stranger, PERSONA).unwrap(); - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.notifications.is_empty()); - } - - /// An election answer from a community we hold no membership with — one holding an - /// election a notification told us of — is checked and handed over only on a thread we sent - /// it (VTI-NTF-063); on any other it has no standing and is never set aside for a check. - #[tokio::test] - async fn an_election_answer_from_a_non_member_community_needs_a_thread_we_sent() { - let (holder, key) = community_key(); - let tdk = test_tdk().await; - let mut config = with_identity(pending_config("did:key:z6MkTheCommunityWeBelongTo")); - let answer = |thid: String| { - let holder = holder.clone(); - let key = key.clone(); - async move { - let mut document = serde_json::from_value(serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": "https://trusttasks.org/spec/trust-task-error/0.1", - "issuer": holder, - "recipient": PERSONA, - "issuedAt": chrono::Utc::now() - .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), - "threadId": thid, - "payload": { "code": "vtc/elections/show:electionUnknown" }, - })) - .unwrap(); - openvtc_core::capabilities::sign_document(&mut document, &key) - .await - .unwrap(); - let wrapped = - vta_sdk::tsp_binding::wrap_envelope(&serde_json::to_vec(&document).unwrap()); - openvtc_core::didcomm::deliver_tsp_document(&wrapped, &holder, PERSONA).unwrap() - } - }; - - // Not on a thread we sent it: not even set aside for a check. - let m = answer(format!("urn:uuid:{}", uuid::Uuid::new_v4())).await; - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.deferred.is_none() && effects.elections_replies.is_empty()); - - // On a thread we sent it: checked, then handed to the Elections panel. - let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); - openvtc_core::elections::expect_answer(&holder, &thid); - let m = answer(thid.clone()).await; - let effects = dispatch_checked(&mut config, &tdk, &m).await; - assert_eq!(effects.elections_replies.len(), 1); - assert_eq!(effects.elections_replies[0].thid, thid); - openvtc_core::elections::answered(&thid); - } - - /// Dispatch `m` as `arrival`, returning the effects. - async fn dispatch( - config: &mut Config, - tdk: &TDK, - m: &Message, - arrival: Arrival<'_>, - ) -> InboundEffects { - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - let mut effects = InboundEffects::default(); - process_inbound_message( - config, - tdk, - &service, - &mut seen, - m, - m.from.as_deref(), - &mut effects, - arrival, - ) - .await - .unwrap(); - effects - } - - /// The local checks run before anything is queued for a network-bound - /// check: a credential or operational document from a party we hold no - /// membership with, an accept that answers nothing of ours, or a - /// community answer to no question of ours is never set aside (so never - /// resolved or fetched for) — it is handled at once, and refused. - #[tokio::test] - async fn only_what_passes_the_local_checks_is_queued_for_a_check() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = pending_config(&vtc); - let stranger = "did:key:z6MkStranger"; - - // A credential "issued" by a stranger, about itself. - let mut vc = membership_vmc(&vtc, &key).await; - vc["issuer"] = serde_json::json!(stranger); - let effects = dispatch( - &mut config, - &tdk, - &issue(stranger, &key, vc).await, - Arrival::FRESH, - ) - .await; - assert!(effects.deferred.is_none()); - // A credential for someone else, from the community. - let mut vc = membership_vmc(&vtc, &key).await; - vc["credentialSubject"]["id"] = serde_json::json!("did:key:z6MkSomeoneElse"); - let effects = dispatch( - &mut config, - &tdk, - &issue(&vtc, &key, vc).await, - Arrival::FRESH, - ) - .await; - assert!(effects.deferred.is_none()); - assert!(matches!( - status(&config, &vtc), - CommunityStatus::Pending { .. } - )); - - // Operational documents from a stranger. - for typ in [ - MEMBER_REMOVAL_NOTICE_TYPE, - JOIN_REQUEST_STATUS_RESPONSE_TYPE, - vta_sdk::protocols::vetting::VETTING_VETTER_LIST_RESPONSE_TYPE, - ] { - let m = Message::build( - uuid::Uuid::new_v4().to_string(), - typ.to_string(), - serde_json::json!({ "payload": { "did": PERSONA } }), - ) - .from(stranger.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize(); - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.deferred.is_none(), "{typ}"); - } - - // A relationship accept answering no request of ours. - let m = Message::build( - uuid::Uuid::new_v4().to_string(), - openvtc_core::protocol_urls::RELATIONSHIP_REQUEST_ACCEPT.to_string(), - serde_json::json!({ "did": stranger }), - ) - .from(stranger.to_string()) - .thid(uuid::Uuid::new_v4().to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize(); - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.deferred.is_none()); - } - - /// A message whose sender has messages queued for a check waits behind - /// them rather than overtaking them, and is handled when its turn comes. - /// - /// The message here has no check of its own, which is what makes it wait - /// as a barrier. (This used `members/request-vmc`, until that became the - /// community's signed document with a check of its own; its ordering is - /// the same, and `a_request_for_our_vmc_waits_for_the_communitys_signature` - /// covers it.) - #[tokio::test] - async fn a_message_behind_a_check_from_its_sender_waits_its_turn() { - let (vtc, _) = community_key(); - let tdk = test_tdk().await; - let mut config = pending_config(&vtc); - let m = Message::build( - uuid::Uuid::new_v4().to_string(), - "https://didcomm.org/trust-ping/2.0/ping".to_string(), - serde_json::json!({ "response_requested": false }), - ) - .from(vtc.clone()) - .to(PERSONA.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize(); - - let busy = |sender: &str| sender == vtc; - let effects = dispatch(&mut config, &tdk, &m, Arrival::New { waiting: &busy }).await; - let deferred = effects.deferred.expect("waits behind its sender's check"); - assert!(deferred.job.is_barrier()); - - // Nobody else is held up. - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - assert!(effects.deferred.is_none()); - - // Back in its turn, it is handled, not set aside again. - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(PreVerified::Barrier), - ) - .await; - assert!(effects.deferred.is_none()); - } - - /// `members/request-vmc` makes this client sign and send a credential, so - /// it is the community's signed operational document or nothing: it is set - /// aside for that check, never acted on for the transport sender alone, and - /// a check that did not pass issues no VMC. - #[tokio::test] - async fn a_request_for_our_vmc_waits_for_the_communitys_signature() { - let (vtc, _) = community_key(); - let tdk = test_tdk().await; - let mut config = pending_config(&vtc); - let m = Message::build( - uuid::Uuid::new_v4().to_string(), - MEMBER_REQUEST_VMC_TYPE.to_string(), - serde_json::json!({}), - ) - .from(vtc.clone()) - .to(PERSONA.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64) - .finalize(); - - let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; - let deferred = effects - .deferred - .expect("a request for our VMC is checked before it is answered"); - assert!( - matches!(deferred.job, VerifyJob::Operational { .. }), - "checked as the community's operational document" - ); - - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(deferred.job.unfinished()), - ) - .await; - assert!(effects.deferred.is_none()); - assert!( - config - .account - .memberships_for(&vtc) - .iter() - .all(|m| m.member_vmc.is_none()), - "an unverified request issues nothing" - ); - } - - /// A message kept across a restart is queued for its check again — past - /// the age gate it already passed. One that no longer passes the local - /// checks is not dropped on restore: it waits its turn, and its handler - /// refuses it then. - #[tokio::test] - async fn a_restored_message_is_queued_again() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = pending_config(&vtc); - let mut m = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; - // Older than the replay window allows a fresh message. - m.created_time = Some(1); - assert!( - dispatch(&mut config, &tdk, &m, Arrival::FRESH) - .await - .deferred - .is_none(), - "a fresh arrival that old is dropped" - ); - let effects = dispatch(&mut config, &tdk, &m, Arrival::Restored).await; - assert!(matches!( - effects.deferred.map(|d| d.job), - Some(VerifyJob::Credential { .. }) - )); - - // The membership went meanwhile: it waits as a barrier, and on its - // turn it is refused — nothing is applied. - config.account = Default::default(); - let deferred = dispatch(&mut config, &tdk, &m, Arrival::Restored) - .await - .deferred - .expect("set aside to wait its turn"); - assert!(deferred.job.is_barrier()); - let effects = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(PreVerified::Barrier), - ) - .await; - assert!(effects.deferred.is_none()); - assert!(config.account.memberships_for(&vtc).is_empty()); - } - - /// A message that waited as a barrier is triaged again on its turn: if it - /// now needs a check (its sender became a community we hold a record - /// with meanwhile), it is set aside for that check rather than refused as - /// unchecked. - #[tokio::test] - async fn a_barrier_is_triaged_again_on_its_turn() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let m = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; - // No record with the community yet: no check, and it waits behind - // its sender's queued work. - let mut config = pending_config(&vtc); - let persona_only = { - let mut c = pending_config(&vtc); - c.account.communities.clear(); - c - }; - let mut early = persona_only; - let busy = |sender: &str| sender == vtc; - let deferred = dispatch(&mut early, &tdk, &m, Arrival::New { waiting: &busy }) - .await - .deferred - .expect("waits its turn"); - assert!(deferred.job.is_barrier()); - // By its turn the join is Pending: it now needs its check. - let again = dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(PreVerified::Barrier), - ) - .await - .deferred - .expect("set aside for the check it now needs"); - assert!(matches!(again.job, VerifyJob::Credential { .. })); - } - - /// A check that did not finish is a refusal: the credential is not stored. - #[tokio::test] - async fn an_unfinished_check_refuses() { - let (vtc, key) = community_key(); - let tdk = test_tdk().await; - let mut config = pending_config(&vtc); - let m = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; - let deferred = dispatch(&mut config, &tdk, &m, Arrival::FRESH) - .await - .deferred - .unwrap(); - let pre = deferred.job.unfinished(); - dispatch( - &mut config, - &tdk, - &deferred.message, - Arrival::Returning(pre), - ) - .await; - assert!(matches!( - status(&config, &vtc), - CommunityStatus::Pending { .. } - )); - } - - /// Every peer-message handler acts only as the sender the transport - /// authenticated. A message whose `from` is not backed by its sealing key - /// — none bound, or another DID — creates no task, removes none, changes no - /// relationship and hands nothing over. - mod sender_binding { - use super::*; - use openvtc_core::protocol_urls; - use openvtc_core::relationships::Relationship; - - const ALICE: &str = "did:key:z6MkAlicePeerInAnEstablishedRelationship"; - const CAROL: &str = "did:key:z6MkCarolSomeoneElse"; - - fn config_with(state: RelationshipState, task_id: &str) -> Config { - let mut config = with_identity(pending_config("did:key:z6MkSomeCommunity")); - let alice = Arc::new(ALICE.to_string()); - config.private.relationships.relationships.insert( - Arc::clone(&alice), - Relationship { - task_id: Arc::new(task_id.to_string()), - our_did: Arc::new(PERSONA.to_string()), - remote_did: Arc::clone(&alice), - remote_p_did: alice, - created: chrono::Utc::now(), - state, - our_persona: None, - needs_reestablishment: false, - }, - ); - config - } - - fn message( - typ: &str, - from: Option<&str>, - thid: Option<&str>, - body: serde_json::Value, - ) -> Message { - let mut b = Message::build(uuid::Uuid::new_v4().to_string(), typ.to_string(), body) - .to(PERSONA.to_string()) - .created_time(chrono::Utc::now().timestamp() as u64); - if let Some(from) = from { - b = b.from(from.to_string()); - } - if let Some(thid) = thid { - b = b.thid(thid.to_string()); - } - b.finalize() - } - - async fn dispatch_as( - config: &mut Config, - m: &Message, - authenticated: Option<&str>, - ) -> InboundEffects { - let tdk = test_tdk().await; - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - let mut effects = InboundEffects::default(); - process_inbound_message( - config, - &tdk, - &service, - &mut seen, - m, - authenticated, - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - effects - } - - /// The two ways a claimed `from` is not the sender: nothing bound, or - /// someone else bound. - const UNBOUND: [Option<&str>; 2] = [None, Some(CAROL)]; - - #[tokio::test] - async fn a_vrc_request_needs_its_sender_authenticated() { - for authenticated in UNBOUND { - let mut config = config_with(RelationshipState::Established, "rel"); - let m = message( - protocol_urls::VRC_REQUEST, - Some(ALICE), - None, - serde_json::json!({ "reason": "please" }), - ); - dispatch_as(&mut config, &m, authenticated).await; - assert!( - config.private.tasks.tasks.is_empty(), - "no inbox task for {authenticated:?}" - ); - } - let mut config = config_with(RelationshipState::Established, "rel"); - let m = message( - protocol_urls::VRC_REQUEST, - Some(ALICE), - None, - serde_json::json!({ "reason": "please" }), - ); - dispatch_as(&mut config, &m, Some(ALICE)).await; - assert_eq!(config.private.tasks.tasks.len(), 1, "authenticated: queued"); - } - - #[tokio::test] - async fn a_vrc_request_rejection_needs_its_sender_authenticated() { - let task = Arc::new(uuid::Uuid::new_v4().to_string()); - let setup = || { - let mut config = config_with(RelationshipState::Established, "rel"); - config.private.tasks.new_task( - &task, - TaskType::VRCRequestOutbound { - remote_p_did: Arc::new(ALICE.to_string()), - }, - ); - config - }; - let reject = || { - message( - protocol_urls::VRC_REJECTED, - Some(ALICE), - Some(&task), - serde_json::json!({ "reason": "no" }), - ) - }; - for authenticated in UNBOUND { - let mut config = setup(); - dispatch_as(&mut config, &reject(), authenticated).await; - assert!(config.private.tasks.get_by_id(&task).is_some()); - } - let mut config = setup(); - dispatch_as(&mut config, &reject(), Some(ALICE)).await; - assert!(config.private.tasks.get_by_id(&task).is_none()); - } - - /// A pong closes only our ping, answered by the party pinged — never a - /// task named by thread id alone, and never for a pong with no - /// authenticated sender. - #[tokio::test] - async fn a_pong_closes_only_our_ping_to_its_authenticated_sender() { - let ping = Arc::new(uuid::Uuid::new_v4().to_string()); - let other = Arc::new(uuid::Uuid::new_v4().to_string()); - let setup = || { - let mut config = config_with(RelationshipState::Established, "rel"); - config.private.tasks.new_task( - &ping, - TaskType::TrustPing { - from: Arc::new(PERSONA.to_string()), - to: Arc::new(ALICE.to_string()), - remote_p_did: Arc::new(ALICE.to_string()), - }, - ); - config.private.tasks.new_task( - &other, - TaskType::VRCRequestOutbound { - remote_p_did: Arc::new(ALICE.to_string()), - }, - ); - config - }; - let pong = |from: Option<&str>, thid: &str| { - message( - protocol_urls::TRUST_PONG, - from, - Some(thid), - serde_json::json!({}), - ) - }; - - // No `from`, nothing authenticated: the old shortcut is gone. - let mut config = setup(); - dispatch_as(&mut config, &pong(None, &ping), None).await; - assert!(config.private.tasks.get_by_id(&ping).is_some()); - - for authenticated in UNBOUND { - let mut config = setup(); - dispatch_as(&mut config, &pong(Some(ALICE), &ping), authenticated).await; - assert!(config.private.tasks.get_by_id(&ping).is_some()); - } - - // Authenticated, but the thread names a task that is not a ping. - let mut config = setup(); - dispatch_as(&mut config, &pong(Some(ALICE), &other), Some(ALICE)).await; - assert!(config.private.tasks.get_by_id(&other).is_some()); - - // Authenticated as someone we did not ping. - let mut config = setup(); - dispatch_as(&mut config, &pong(Some(CAROL), &ping), Some(CAROL)).await; - assert!(config.private.tasks.get_by_id(&ping).is_some()); - - let mut config = setup(); - dispatch_as(&mut config, &pong(Some(ALICE), &ping), Some(ALICE)).await; - assert!(config.private.tasks.get_by_id(&ping).is_none()); - assert!(config.private.tasks.get_by_id(&other).is_some()); - } - - #[tokio::test] - async fn a_relationship_rejection_needs_its_sender_authenticated() { - let thid = uuid::Uuid::new_v4().to_string(); - let reject = || { - message( - protocol_urls::RELATIONSHIP_REQUEST_REJECT, - Some(ALICE), - Some(&thid), - serde_json::json!({ "reason": "no" }), - ) - }; - for authenticated in UNBOUND { - let mut config = config_with(RelationshipState::RequestSent, &thid); - dispatch_as(&mut config, &reject(), authenticated).await; - assert_eq!(config.private.relationships.relationships.len(), 1); - } - let mut config = config_with(RelationshipState::RequestSent, &thid); - dispatch_as(&mut config, &reject(), Some(ALICE)).await; - assert!(config.private.relationships.relationships.is_empty()); - } - - #[tokio::test] - async fn a_relationship_finalize_needs_its_sender_authenticated() { - let thid = uuid::Uuid::new_v4().to_string(); - let finalize = || { - message( - protocol_urls::RELATIONSHIP_REQUEST_FINALIZE, - Some(ALICE), - Some(&thid), - serde_json::json!({}), - ) - }; - let state = |config: &Config| { - config - .private - .relationships - .relationships - .values() - .next() - .map(|r| r.state.clone()) - }; - for authenticated in UNBOUND { - let mut config = config_with(RelationshipState::RequestAccepted, &thid); - dispatch_as(&mut config, &finalize(), authenticated).await; - assert_eq!(state(&config), Some(RelationshipState::RequestAccepted)); - } - let mut config = config_with(RelationshipState::RequestAccepted, &thid); - dispatch_as(&mut config, &finalize(), Some(ALICE)).await; - assert_eq!(state(&config), Some(RelationshipState::Established)); - } - - /// A message dropped for its sender does not enter the replay cache, - /// so the authenticated message with the same id is still handled. - #[tokio::test] - async fn an_unbound_message_does_not_use_up_its_id() { - let tdk = test_tdk().await; - let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); - let service = openvtc_core::didcomm::start_empty_service( - tx, - tokio_util::sync::CancellationToken::new(), - ); - let mut seen = SeenMessages::new(); - let mut config = config_with(RelationshipState::Established, "rel"); - let m = message( - protocol_urls::VRC_REQUEST, - Some(ALICE), - None, - serde_json::json!({ "reason": "please" }), - ); - for authenticated in [None, Some(ALICE)] { - let mut effects = InboundEffects::default(); - process_inbound_message( - &mut config, - &tdk, - &service, - &mut seen, - &m, - authenticated, - &mut effects, - Arrival::FRESH, - ) - .await - .unwrap(); - } - assert_eq!(config.private.tasks.tasks.len(), 1); - } - - #[tokio::test] - async fn a_trust_ping_task_needs_its_sender_authenticated() { - for authenticated in UNBOUND { - let mut config = config_with(RelationshipState::Established, "rel"); - let m = message( - protocol_urls::TRUST_PING, - Some(ALICE), - None, - serde_json::json!({}), - ); - dispatch_as(&mut config, &m, authenticated).await; - assert!(config.private.tasks.tasks.is_empty()); - } - } - - /// A notification is handed over and answered with a receipt to its - /// sender; with no authenticated sender there is nobody to answer. - #[tokio::test] - async fn a_notification_needs_its_sender_authenticated() { - let document = || { - serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": openvtc_core::notifications::NOTIFY_TYPE, - "issuer": ALICE, - "recipient": PERSONA, - "issuedAt": "2026-11-03T00:00:00.000000Z", - "payload": { "notification": { "notificationId": "n-1" } }, - }) - }; - for authenticated in UNBOUND { - let mut config = config_with(RelationshipState::Established, "rel"); - let m = message( - openvtc_core::notifications::NOTIFY_TYPE, - Some(ALICE), - None, - document(), - ); - let effects = dispatch_as(&mut config, &m, authenticated).await; - assert!(effects.notifications.is_empty(), "{authenticated:?}"); - } - let mut config = config_with(RelationshipState::Established, "rel"); - let m = message( - openvtc_core::notifications::NOTIFY_TYPE, - Some(ALICE), - None, - document(), - ); - let effects = dispatch_as(&mut config, &m, Some(ALICE)).await; - assert_eq!(effects.notifications.len(), 1); - assert_eq!(effects.notifications[0].sender, ALICE); - } - } -} +mod tests; diff --git a/openvtc-core/src/inbound/registry.rs b/openvtc-core/src/inbound/registry.rs new file mode 100644 index 00000000..b486b93e --- /dev/null +++ b/openvtc-core/src/inbound/registry.rs @@ -0,0 +1,295 @@ +//! The one list of inbound message types this client handles. +//! +//! Two decisions used to describe the same fact separately: the transport's +//! accept gate (a hand-written regex in `didcomm.rs`) and the dispatcher's +//! chain of `if message.typ == …` branches. A branch added without a matching +//! gate prefix was a verb that failed totally and silently — the message was +//! dropped at `debug!` before the dispatcher saw it, and the sender waited out +//! its timeout. It happened (capability replies, `git-ns` answers over TSP), and +//! a hand-maintained census test was the only thing reconciling the two. +//! +//! Now both are derived from [`ROUTES`]: +//! +//! - the gate ([`crate::didcomm::routes_inbound_type`]) admits a type exactly +//! when [`classify`] gives it a [`Family`]; +//! - the dispatcher matches on that [`Family`], exhaustively, so every family +//! has a handler or the crate does not compile. +//! +//! So a handler cannot exist for a type the gate refuses, and the gate admits +//! nothing no handler claims. The routes are disjoint — at most one matches a +//! type — so their order carries no meaning; a test holds them to that. +//! +//! A family's handler may still decline a message (unsigned, from a stranger, +//! threading on nothing of ours); that is the handler's decision, logged there. +//! A few families are *shared*: a `trust-task-error` may answer any request we +//! sent, so its handler offers it to each view in turn, and vetting reads some +//! credential deliveries and problem reports before the join handlers do. The +//! sharing is inside those handlers, written out once, not spread across the +//! routing. + +use crate::capabilities::TRUST_TASK_ENVELOPE_TYPE; +use crate::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE; +use crate::messaging::{TRUST_TASK_ERROR_TYPE_PREFIX, is_trust_task_error_type}; +use crate::personhood::{PERSONHOOD_ASSERT_RESPONSE_TYPE, PERSONHOOD_CHALLENGE_RESPONSE_TYPE}; +use crate::protocol_urls; +use crate::vetting::protocol::JoinProtocol; +use vta_sdk::protocols::PROBLEM_REPORT_TYPE; +use vta_sdk::protocols::credential_exchange::ISSUE as CREDENTIAL_ISSUE_TYPE; +use vta_sdk::protocols::join_requests::{ + JOIN_REQUEST_STATUS_RESPONSE_TYPE, JOIN_REQUEST_SUBMIT_RECEIPT_TYPE, +}; +use vta_sdk::protocols::members::{ + MEMBER_REMOVAL_NOTICE_TYPE, MEMBER_REQUEST_VMC_TYPE, MEMBER_VMC_RESPONSE_TYPE, +}; + +/// Declares [`Family`] and [`Family::ALL`] from one list, so a new family is +/// in `ALL` by construction. +macro_rules! families { + ($($(#[$meta:meta])* $variant:ident,)*) => { + /// What an inbound message is, for routing: one handler per family. + /// + /// Exhaustively matched by the dispatcher, so a family without a handler + /// does not compile. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum Family { + $($(#[$meta])* $variant,)* + } + + impl Family { + /// Every family, in declaration order. + pub const ALL: &'static [Family] = &[$(Family::$variant,)*]; + } + }; +} + +families! { + /// `notifications/notify` and `notifications/relay`, for the member's VTA. + Notification, + /// `governance/capability/*` answers. + CapabilityReply, + /// `git-ns/*` answers, for the Repos panel. + GitNsReply, + /// `vtc/rooms/*` answers, for the Rooms panel. + RoomsReply, + /// `vtc/elections/*` answers, for the Elections panel. + ElectionsReply, + /// A witness's `witness/session/*#response`. + WitnessReply, + /// Peer identity vetting, and a community's answers to vetting questions. + Vetting, + /// `join-requests/submit-receipt`. + JoinSubmitReceipt, + /// `credential-exchange/issue`. Vetting reads it first. + CredentialIssue, + /// `join-requests/submit#response`: the admission verdict. + JoinVerdict, + /// A DIDComm problem-report. Vetting reads it first. + ProblemReport, + /// `vtc/members/renew#response`. + RenewalResponse, + /// A `trust-task-error`, offered to every request it may refuse. + TrustTaskError, + /// `join-requests/status#response`. + JoinStatusResponse, + /// `vtc/profile/show#response`. + CommunityProfileResponse, + /// The personhood challenge. + PersonhoodChallengeResponse, + /// The personhood assertion's result. + PersonhoodAssertResponse, + /// `members/vmc#response`: the community took our VMC. + MemberVmcResponse, + /// `members/request-vmc`: the community asks for our VMC. + MemberRequestVmc, + /// `vtc/members/removal-notice`. + MemberRemovalNotice, + /// The DIDComm binding envelope, opened before routing. One that does not + /// open is dropped. + BindingEnvelope, + /// OpenVTC relationship request. + RelationshipRequest, + /// OpenVTC relationship request rejected. + RelationshipRequestRejected, + /// OpenVTC relationship request accepted. + RelationshipRequestAccepted, + /// OpenVTC relationship finalize. + RelationshipRequestFinalize, + /// DIDComm trust-ping. The transport answers it; the dispatcher records it. + TrustPing, + /// DIDComm trust-ping response. + TrustPong, + /// VRC request. + VrcRequest, + /// VRC request rejected. + VrcRequestRejected, + /// VRC issued. + VrcIssued, +} + +/// How a route recognises its types. +#[derive(Clone, Copy)] +pub enum TypeMatcher { + /// Exactly this type URI. + Exact(&'static str), + /// Any type URI starting with this prefix (every version of a family). + Prefix(&'static str), + /// A family whose membership is defined by its own module — kept there so + /// the module that parses the types is the one that names them. + Predicate(fn(&str) -> bool), +} + +impl TypeMatcher { + /// Whether `typ` is one of this route's types. + #[must_use] + pub fn matches(&self, typ: &str) -> bool { + match self { + TypeMatcher::Exact(t) => typ == *t, + TypeMatcher::Prefix(p) => typ.starts_with(p), + TypeMatcher::Predicate(f) => f(typ), + } + } +} + +impl std::fmt::Debug for TypeMatcher { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TypeMatcher::Exact(t) => write!(f, "Exact({t})"), + TypeMatcher::Prefix(p) => write!(f, "Prefix({p})"), + TypeMatcher::Predicate(_) => f.write_str("Predicate(..)"), + } + } +} + +/// One entry of the registry: these types are this family's. +#[derive(Debug, Clone, Copy)] +pub struct Route { + /// Which types. + pub matcher: TypeMatcher, + /// Whose. + pub family: Family, +} + +const fn exact(typ: &'static str, family: Family) -> Route { + Route { + matcher: TypeMatcher::Exact(typ), + family, + } +} + +const fn prefix(prefix: &'static str, family: Family) -> Route { + Route { + matcher: TypeMatcher::Prefix(prefix), + family, + } +} + +const fn predicate(f: fn(&str) -> bool, family: Family) -> Route { + Route { + matcher: TypeMatcher::Predicate(f), + family, + } +} + +/// Vetting's own types: everything [`crate::vetting::inbound::may_claim`] +/// names except the three it shares with other families (a credential +/// delivery, a problem-report, a `trust-task-error`), whose handlers offer +/// them to vetting first. +fn vetting_owned(typ: &str) -> bool { + crate::vetting::inbound::may_claim(typ) + && typ != CREDENTIAL_ISSUE_TYPE + && typ != PROBLEM_REPORT_TYPE + && !is_trust_task_error_type(typ) +} + +/// Every inbound type this client handles, and whose it is. The gate and the +/// dispatcher both read this; nothing else decides either. +pub const ROUTES: &[Route] = &[ + exact(crate::notifications::NOTIFY_TYPE, Family::Notification), + exact(crate::notifications::RELAY_TYPE, Family::Notification), + prefix( + crate::capabilities::CAPABILITY_TYPE_PREFIX, + Family::CapabilityReply, + ), + prefix(crate::git_ns::TYPE_PREFIX, Family::GitNsReply), + prefix(crate::community_rooms::TYPE_PREFIX, Family::RoomsReply), + prefix(crate::elections::TYPE_PREFIX, Family::ElectionsReply), + predicate(crate::witness::is_reply_type, Family::WitnessReply), + predicate(vetting_owned, Family::Vetting), + exact(JOIN_REQUEST_SUBMIT_RECEIPT_TYPE, Family::JoinSubmitReceipt), + exact(CREDENTIAL_ISSUE_TYPE, Family::CredentialIssue), + predicate(JoinProtocol::is_submit_response, Family::JoinVerdict), + exact(PROBLEM_REPORT_TYPE, Family::ProblemReport), + exact( + crate::renewal::MEMBERS_RENEW_RESPONSE_TYPE, + Family::RenewalResponse, + ), + prefix(TRUST_TASK_ERROR_TYPE_PREFIX, Family::TrustTaskError), + exact( + JOIN_REQUEST_STATUS_RESPONSE_TYPE, + Family::JoinStatusResponse, + ), + exact( + COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE, + Family::CommunityProfileResponse, + ), + exact( + PERSONHOOD_CHALLENGE_RESPONSE_TYPE, + Family::PersonhoodChallengeResponse, + ), + exact( + PERSONHOOD_ASSERT_RESPONSE_TYPE, + Family::PersonhoodAssertResponse, + ), + exact(MEMBER_VMC_RESPONSE_TYPE, Family::MemberVmcResponse), + exact(MEMBER_REQUEST_VMC_TYPE, Family::MemberRequestVmc), + exact(MEMBER_REMOVAL_NOTICE_TYPE, Family::MemberRemovalNotice), + exact(TRUST_TASK_ENVELOPE_TYPE, Family::BindingEnvelope), + exact( + protocol_urls::RELATIONSHIP_REQUEST, + Family::RelationshipRequest, + ), + exact( + protocol_urls::RELATIONSHIP_REQUEST_REJECT, + Family::RelationshipRequestRejected, + ), + exact( + protocol_urls::RELATIONSHIP_REQUEST_ACCEPT, + Family::RelationshipRequestAccepted, + ), + exact( + protocol_urls::RELATIONSHIP_REQUEST_FINALIZE, + Family::RelationshipRequestFinalize, + ), + exact(protocol_urls::TRUST_PING, Family::TrustPing), + exact(protocol_urls::TRUST_PONG, Family::TrustPong), + exact(protocol_urls::VRC_REQUEST, Family::VrcRequest), + exact(protocol_urls::VRC_REJECTED, Family::VrcRequestRejected), + exact(protocol_urls::VRC_ISSUED, Family::VrcIssued), +]; + +/// The family `typ` belongs to, or `None` when no handler claims it — in +/// which case the gate drops it before dispatch. +#[must_use] +pub fn classify(typ: &str) -> Option { + ROUTES + .iter() + .find(|route| route.matcher.matches(typ)) + .map(|route| route.family) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every family is reachable: some route names it. A family with a + /// handler and no route would be dead code the gate never admits. + #[test] + fn every_family_has_a_route() { + for family in Family::ALL { + assert!( + ROUTES.iter().any(|r| r.family == *family), + "{family:?} has no route, so the gate never admits a message for it" + ); + } + } +} diff --git a/openvtc-core/src/inbound/tests.rs b/openvtc-core/src/inbound/tests.rs new file mode 100644 index 00000000..6356be9f --- /dev/null +++ b/openvtc-core/src/inbound/tests.rs @@ -0,0 +1,1546 @@ +use super::*; +use crate::config::account::{CommunityRecord, CommunityStatus, PersonaId, PersonaRecord}; +use crate::test_support::{test_config, test_tdk}; +use affinidi_tdk::secrets_resolver::secrets::Secret; + +const PERSONA: &str = "did:webvh:QmP:example.com:alice"; + +fn community_key() -> (String, Secret) { + let mut s = Secret::generate_ed25519(None, Some(&[0x71; 32])); + let mb = s.get_public_keymultibase().unwrap(); + let did = format!("did:key:{mb}"); + s.id = format!("{did}#{mb}"); + (did, s) +} + +/// A config holding one persona with a Pending join to `vtc`. +fn pending_config(vtc: &str) -> Config { + let mut config = test_config(); + let pid = PersonaId::new(); + config.account.personas.insert( + pid, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id: pid, + did: PERSONA.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: "openvtc".into(), + created_at: chrono::Utc::now(), + label: None, + }, + ); + config.account.add_membership(CommunityRecord::new_pending( + vtc.into(), + None, + "openvtc/x".into(), + pid, + uuid::Uuid::new_v4(), + chrono::Utc::now(), + )); + config +} + +/// An `issue` as a VTC pushes it: a Trust Task document signed by the +/// community's key, opened out of the binding envelope. +async fn issue(vtc: &str, key: &Secret, credential: serde_json::Value) -> Message { + let mut document = serde_json::from_value(serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": CREDENTIAL_ISSUE_TYPE, + "issuer": vtc, + "recipient": PERSONA, + "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "payload": { "credential_response": { "credential": credential } }, + })) + .expect("an issue document"); + crate::capabilities::sign_document(&mut document, key) + .await + .expect("sign the delivery"); + Message::build( + uuid::Uuid::new_v4().to_string(), + CREDENTIAL_ISSUE_TYPE.to_string(), + serde_json::to_value(&document).expect("document json"), + ) + .from(vtc.to_string()) + .to(PERSONA.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize() +} + +async fn membership_vmc(vtc: &str, key: &Secret) -> serde_json::Value { + let mut vc = serde_json::json!({ + "@context": [ + dtg_credentials::W3C_VC_V2_CONTEXT, + dtg_credentials::DTG_CONTEXT_V1 + ], + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": ["VerifiableCredential", "DTGCredential", "MembershipCredential"], + "issuer": vtc, + "issuerScope": "public", + "validFrom": "2026-01-01T00:00:00Z", + "validUntil": "2099-01-01T00:00:00Z", + "credentialSubject": { "id": PERSONA }, + }); + let proof = affinidi_data_integrity::DataIntegrityProof::sign( + &vc, + key, + affinidi_data_integrity::SignOptions::new(), + ) + .await + .unwrap(); + vc["proof"] = serde_json::to_value(proof).unwrap(); + vc +} + +fn status(config: &Config, vtc: &str) -> CommunityStatus { + config.account.memberships_for(vtc)[0].status.clone() +} + +/// A credential delivery is not handled on arrival: it is set aside for +/// its check, and nothing — no credential, no activation — is applied +/// until the check's result comes back. Then a verified credential +/// activates the join, and a refused one leaves it Pending. +#[tokio::test] +async fn a_credential_is_verified_off_the_loop_before_anything_is_applied() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + + // A forged (unsigned) credential: deferred, then refused. + let mut config = pending_config(&vtc); + let mut forged = membership_vmc(&vtc, &key).await; + forged.as_object_mut().unwrap().remove("proof"); + let m = issue(&vtc, &key, forged).await; + let mut effects = InboundEffects::default(); + let changed = process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &m, + m.from.as_deref(), + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + assert!(!changed, "nothing applied on arrival"); + let deferred = effects.deferred.take().expect("set aside for its check"); + assert_eq!(deferred.job.verifying_community(), Some(vtc.as_str())); + assert!(matches!( + status(&config, &vtc), + CommunityStatus::Pending { .. } + )); + let pre = deferred.job.run(tdk.clone()).await; + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &deferred.message, + deferred.message.from.as_deref(), + &mut effects, + Arrival::Returning(pre), + ) + .await + .unwrap(); + assert!( + effects.deferred.is_none(), + "handled on its return, not set aside again" + ); + assert!( + matches!(status(&config, &vtc), CommunityStatus::Pending { .. }), + "refused: still Pending" + ); + assert!( + config.account.memberships_for(&vtc)[0] + .credentials + .is_empty() + ); + + // A genuine credential: deferred, then it activates. + let mut config = pending_config(&vtc); + let genuine = membership_vmc(&vtc, &key).await; + let m = issue(&vtc, &key, genuine).await; + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &m, + m.from.as_deref(), + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + let deferred = effects.deferred.take().expect("set aside"); + assert!( + matches!(status(&config, &vtc), CommunityStatus::Pending { .. }), + "not before its check" + ); + let pre = deferred.job.run(tdk.clone()).await; + assert!(matches!(&pre, PreVerified::Credential(Ok(_)))); + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &deferred.message, + deferred.message.from.as_deref(), + &mut effects, + Arrival::Returning(pre), + ) + .await + .unwrap(); + assert!(status(&config, &vtc).is_active(), "activated once verified"); +} + +/// A community's answer to a hidden-vetting question — here a vetter's enrolment, in the +/// binding envelope the VTC replies in — reaches the vetting router and takes the question +/// it threads on. It used to be dropped before the router saw it, so the question waited +/// out its 30 seconds as "no answer" and the vetter never held a token to attest with. +#[tokio::test] +async fn a_hidden_vetting_answer_reaches_the_question_it_answers() { + use crate::vetting::queries::{CommunityQuery, QueryKind}; + use crate::vetting::wire::pcs; + + let (vtc, _key) = community_key(); + let tdk = test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut config = pending_config(&vtc); + let persona = *config.account.personas.keys().next().unwrap(); + + for (kind, task, payload) in [ + ( + QueryKind::PcsRoot, + pcs::ROOT_TYPE, + serde_json::json!({ "label": "vetter/2026-10", "preCredential": "z1" }), + ), + ( + QueryKind::PcsTokens, + pcs::TOKENS_TYPE, + serde_json::json!({ "label": "token/2026-10", "tick": 0, "preCredentials": [] }), + ), + ] { + let asked = format!("urn:uuid:{}", uuid::Uuid::new_v4()); + config.private.vetting.ask(CommunityQuery { + document_id: asked.clone(), + community: vtc.clone(), + persona, + kind, + sent_at: chrono::Utc::now(), + }); + let answer = Message::build( + uuid::Uuid::new_v4().to_string(), + crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(), + serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": pcs::response_of(task), + "threadId": asked, + "issuer": vtc, + "recipient": PERSONA, + "payload": payload, + }), + ) + .from(vtc.clone()) + .to(PERSONA.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize(); + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &answer, + answer.from.as_deref(), + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + assert!( + !config + .private + .vetting + .queries + .iter() + .any(|q| q.document_id == asked), + "the {kind:?} answer was heard, so its question no longer waits to time out" + ); + } +} + +/// A result is taken only for the credential it was produced for. +#[tokio::test] +async fn a_check_result_for_another_credential_is_not_taken() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut config = pending_config(&vtc); + + let other = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &other, + other.from.as_deref(), + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + let pre = effects.deferred.take().unwrap().job.run(tdk.clone()).await; + + // A different credential, handed back with the first one's result. + let mut different = membership_vmc(&vtc, &key).await; + different["id"] = serde_json::json!("urn:uuid:different"); + let m = issue(&vtc, &key, different).await; + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &m, + m.from.as_deref(), + &mut effects, + Arrival::Returning(pre), + ) + .await + .unwrap(); + assert!(matches!( + status(&config, &vtc), + CommunityStatus::Pending { .. } + )); +} + +/// Operational documents (here a removal notice) are set aside too, and +/// applied only with their check's result. +#[tokio::test] +async fn a_removal_notice_waits_for_its_check() { + let (vtc, _) = community_key(); + let tdk = test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut config = pending_config(&vtc); + let m = Message::build( + uuid::Uuid::new_v4().to_string(), + MEMBER_REMOVAL_NOTICE_TYPE.to_string(), + serde_json::json!({ "payload": { "did": PERSONA } }), + ) + .from(vtc.clone()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize(); + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &m, + m.from.as_deref(), + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + let deferred = effects.deferred.expect("set aside for its check"); + assert!(matches!(deferred.job, VerifyJob::Operational { .. })); + assert_eq!(deferred.job.verifying_community(), None); +} + +/// A config holding one persona with an Active membership of `vtc` whose +/// pre-v1 credentials were set aside on load — what the v1 migration leaves. +/// +/// The persona also has a runtime identity, so a reply addressed to it is +/// recognised as ours (`Config::is_persona_did`), as it is at run time. +fn retired_config(vtc: &str) -> Config { + let mut config = with_identity(pending_config(vtc)); + let record = &mut config.account.communities.get_mut(vtc).unwrap()[0]; + record.activate(chrono::Utc::now()); + for kind in ["Membership", "Role"] { + record + .retired_credentials + .push(crate::config::account::RetiredCredential { + kind: kind.into(), + credential_id: None, + reason: "pre-v1".into(), + retired_at: chrono::Utc::now(), + }); + } + config +} + +/// `config` with a runtime identity for `PERSONA`, so a reply addressed +/// to it is recognised as ours (`Config::is_persona_did`), as it is at run +/// time. +fn with_identity(mut config: Config) -> Config { + use affinidi_tdk::messaging::profiles::{ATMProfile, ATMProfileInner}; + let pid = config.account.persona_id_for_did(PERSONA).unwrap(); + config.identities.insert( + pid, + crate::identity::IdentityContext { + persona_id: pid, + did: PERSONA.to_string(), + document: serde_json::from_value(serde_json::json!({ "id": PERSONA })) + .expect("minimal DID document"), + profile: Arc::new(ATMProfile { + inner: Arc::new(ATMProfileInner { + did: PERSONA.to_string(), + alias: PERSONA.to_string(), + mediator: Arc::new(None), + }), + }), + mediator_did: None, + }, + ); + config +} + +/// A Trust Task document from the community to `PERSONA`, threaded on +/// `thid`, signed under `authentication` and carried in the binding +/// envelope — the shape a VTC answers a member verb with. +async fn community_reply( + vtc: &str, + key: &Secret, + typ: &str, + thid: &str, + payload: serde_json::Value, +) -> Message { + let mut document = serde_json::from_value(serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": typ, + "issuer": vtc, + "recipient": PERSONA, + "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "threadId": thid, + "payload": payload, + })) + .expect("a reply document"); + crate::capabilities::sign_document(&mut document, key) + .await + .expect("sign the reply"); + Message::build( + uuid::Uuid::new_v4().to_string(), + crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string(), + serde_json::to_value(&document).expect("document json"), + ) + .from(vtc.to_string()) + .to(PERSONA.to_string()) + .thid(thid.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize() +} + +/// A community role VAC conferring `role:member`, signed by `key`. +async fn role_vac(vtc: &str, key: &Secret) -> serde_json::Value { + let vac = DTGCredential::new_community_role_vac( + vtc.to_string(), + PERSONA.to_string(), + "member", + chrono::Utc::now() - chrono::Duration::minutes(1), + chrono::Utc::now() + chrono::Duration::days(365), + ) + .unwrap() + .with_max_attenuation(0) + .unwrap(); + let mut vc = serde_json::to_value(vac).unwrap(); + let proof = affinidi_data_integrity::DataIntegrityProof::sign( + &vc, + key, + affinidi_data_integrity::SignOptions::new(), + ) + .await + .unwrap(); + vc["proof"] = serde_json::to_value(proof).unwrap(); + vc +} + +/// **Renewal, end to end through the dispatcher.** The community's answer +/// to a renewal we asked for is set aside for its check, and only then +/// stored: the re-issued VMC and role VAC replace the set-aside pre-v1 +/// credentials, and the notices go with them. +#[tokio::test] +async fn a_renewal_reply_is_checked_then_stored_clearing_the_retired_notices() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = retired_config(&vtc); + let request = uuid::Uuid::new_v4(); + crate::renewal::record_request(request, &vtc, PERSONA); + + let vmc = membership_vmc(&vtc, &key).await; + let vac = role_vac(&vtc, &key).await; + let m = community_reply( + &vtc, + &key, + crate::renewal::MEMBERS_RENEW_RESPONSE_TYPE, + &format!("urn:uuid:{request}"), + serde_json::json!({ + "did": PERSONA, + "vmc": vmc, + "roleVac": vac, + "personhood": false, + "personhoodChanged": false, + }), + ) + .await; + + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let deferred = effects.deferred.expect("set aside for its check"); + assert!(matches!(deferred.job, VerifyJob::Renewal { .. })); + assert!( + config.account.memberships_for(&vtc)[0] + .credentials + .is_empty(), + "nothing stored before the check" + ); + + let pre = deferred.job.run(tdk.clone()).await; + assert!(matches!(&pre, PreVerified::Renewal(Ok(_)))); + let effects = dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(pre), + ) + .await; + + let record = &config.account.memberships_for(&vtc)[0]; + assert_eq!( + record.credentials.get(&crate::CredentialKind::Membership), + Some(&vmc) + ); + assert_eq!( + record.credentials.get(&crate::CredentialKind::Role), + Some(&vac) + ); + assert!(record.retired_credentials.is_empty(), "the notices clear"); + let [(from, outcome)] = effects.renewals.as_slice() else { + panic!("one renewal outcome"); + }; + assert_eq!(from, &vtc); + let crate::renewal::RenewalOutcome::Renewed { + summary, + acknowledged, + } = outcome + else { + panic!("renewed, got {outcome:?}"); + }; + assert_eq!(summary.roles, vec!["member".to_string()]); + // The test config has no messaging identity, so the acknowledgement + // cannot go out — and the member is told how to send it. + assert!(acknowledged.is_err()); + assert!(outcome.describe().contains("press m")); + + // Answered once: the same reply again is not taken. + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.deferred.is_none()); + assert!(effects.renewals.is_empty()); +} + +/// A reply threading on no renewal of ours is never checked or stored. +#[tokio::test] +async fn a_renewal_reply_we_did_not_ask_for_is_ignored() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = retired_config(&vtc); + let m = community_reply( + &vtc, + &key, + crate::renewal::MEMBERS_RENEW_RESPONSE_TYPE, + &format!("urn:uuid:{}", uuid::Uuid::new_v4()), + serde_json::json!({ + "did": PERSONA, + "vmc": membership_vmc(&vtc, &key).await, + "roleVac": role_vac(&vtc, &key).await, + "personhood": false, + "personhoodChanged": false, + }), + ) + .await; + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.deferred.is_none()); + assert!(effects.renewals.is_empty()); + assert!( + config.account.memberships_for(&vtc)[0] + .credentials + .is_empty() + ); +} + +/// **notMember.** The community's signed refusal of our renewal reaches +/// the member as such — not as a join refusal, and with nothing changed. +#[tokio::test] +async fn a_renewal_refused_as_not_a_member_is_reported() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = retired_config(&vtc); + let request = uuid::Uuid::new_v4(); + crate::renewal::record_request(request, &vtc, PERSONA); + let m = community_reply( + &vtc, + &key, + "https://trusttasks.org/spec/trust-task-error/0.1", + &format!("urn:uuid:{request}"), + serde_json::json!({ + "code": crate::renewal::RENEW_NOT_MEMBER, + "message": "no ACL row — not a member", + "retryable": false, + }), + ) + .await; + + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let deferred = effects.deferred.expect("set aside for its check"); + let pre = deferred.job.run(tdk.clone()).await; + let effects = dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(pre), + ) + .await; + + let [(_, outcome)] = effects.renewals.as_slice() else { + panic!("one renewal outcome"); + }; + assert_eq!( + outcome, + &crate::renewal::RenewalOutcome::Refused(crate::renewal::RenewalRefusal::NotMember) + ); + let record = &config.account.memberships_for(&vtc)[0]; + assert!(record.status.is_active(), "a refused renewal ends nothing"); + assert_eq!(record.retired_credentials.len(), 2); + assert!(!crate::renewal::is_pending(&vtc, &request.to_string())); +} + +/// Collects what is logged at `WARN` and above while it is the default +/// subscriber on this thread. +#[derive(Clone, Default)] +struct Warnings(Arc>>); + +impl std::io::Write for Warnings { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +impl Warnings { + fn capture(&self) -> tracing::subscriber::DefaultGuard { + let sink = self.clone(); + tracing::subscriber::set_default( + tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .with_ansi(false) + .with_writer(move || sink.clone()) + .finish(), + ) + } + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +/// Dispatch `m` once it has been through its off-loop check, as the loop +/// does: on arrival it is set aside, then handled with the check's result. +async fn dispatch_checked(config: &mut Config, tdk: &TDK, m: &Message) -> InboundEffects { + let effects = dispatch(config, tdk, m, Arrival::FRESH).await; + let deferred = effects.deferred.expect("set aside for its check"); + let pre = deferred.job.run(tdk.clone()).await; + dispatch(config, tdk, &deferred.message, Arrival::Returning(pre)).await +} + +/// A witness's answer reaches the witnessing waiting on it whoever sent it — a witness is +/// no contact and no community — in either carriage, and is not read here: the witnessing +/// verifies it as the witness's. A refusal (`trust-task-error`) is offered too. +#[tokio::test(flavor = "current_thread")] +async fn a_witness_answer_is_handed_to_the_witnessing() { + let tdk = test_tdk().await; + let mut config = test_config(); + let witness = "did:key:z6MkWitness"; + let session = "urn:uuid:9e8d7c6b-0000-4000-8000-0000000000aa"; + for (typ, enveloped) in [ + ( + "https://trusttasks.org/spec/witness/session/0.2#response", + true, + ), + ( + "https://trusttasks.org/spec/witness/session/submit/0.2#response", + false, + ), + ("https://trusttasks.org/spec/trust-task-error/0.5", true), + ] { + let document = serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": typ, + "threadId": session, + "issuer": witness, + "recipient": "did:peer:2.Vz6MkOurs", + "payload": {}, + }); + let message = Message::build( + format!("urn:uuid:{}", uuid::Uuid::new_v4()), + if enveloped { + crate::capabilities::TRUST_TASK_ENVELOPE_TYPE.to_string() + } else { + typ.to_string() + }, + document.clone(), + ) + .from(witness.to_string()) + .to("did:peer:2.Vz6MkOurs".to_string()) + .finalize(); + let effects = dispatch(&mut config, &tdk, &message, Arrival::FRESH).await; + assert!( + effects.deferred.is_none(), + "{typ}: nothing to check off the loop" + ); + let [reply] = effects.witness_replies.as_slice() else { + panic!( + "{typ}: one answer for the witnessing, got {:?}", + effects.witness_replies + ); + }; + assert_eq!(reply.sender, witness); + assert_eq!(reply.document, document); + } +} + +/// A community that offers no capability management refuses the query +/// with a signed `trust-task-error`. It is proven and recorded once, for +/// the capability view, and then falls through the other refusal handlers +/// — which must not re-check it, find it already recorded, and warn that a +/// community refusal was ignored as a replay. +#[tokio::test(flavor = "current_thread")] +async fn a_capability_refusal_is_not_reported_as_its_own_replay() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = retired_config(&vtc); + let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); + let m = community_reply( + &vtc, + &key, + "https://trusttasks.org/spec/trust-task-error/0.1", + &thid, + serde_json::json!({ + "code": "unsupportedType", + "message": "governance/capability/list is not served here", + "retryable": false, + }), + ) + .await; + + let warnings = Warnings::default(); + let effects = { + let _guard = warnings.capture(); + dispatch_checked(&mut config, &tdk, &m).await + }; + + let [(from, reply_thid, reply)] = effects.capability_replies.as_slice() else { + panic!("one capability reply, got {:?}", effects.capability_replies); + }; + assert_eq!(from, &vtc); + assert_eq!(reply_thid, &thid); + assert!(matches!( + reply, + crate::capabilities::CapabilityReply::Rejected { code, .. } + if code == "unsupportedType" + )); + let logged = warnings.text(); + assert!(!logged.contains("replay"), "{logged}"); + assert!(!logged.contains("refusal ignored"), "{logged}"); + assert!( + !logged.contains("did not match a pending request"), + "{logged}" + ); + assert!( + config.account.memberships_for(&vtc)[0].status.is_active(), + "a capability refusal ends nothing" + ); + + // Replay protection across messages is untouched: the same refusal + // again is not handed to the view a second time. + let effects = dispatch_checked(&mut config, &tdk, &m).await; + assert!(effects.capability_replies.is_empty()); +} + +/// A genuine join refusal still reaches the join handler, though it too is +/// proven first by the capability arm: a `permissionDenied` threaded on +/// our submit rejects the pending join. +#[tokio::test(flavor = "current_thread")] +async fn a_join_refusal_still_rejects_the_pending_join() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = with_identity(pending_config(&vtc)); + let CommunityStatus::Pending { request_id } = status(&config, &vtc) else { + panic!("pending"); + }; + let m = community_reply( + &vtc, + &key, + "https://trusttasks.org/spec/trust-task-error/0.1", + &request_id.to_string(), + serde_json::json!({ + "code": "permissionDenied", + "message": "not admitted", + "retryable": false, + }), + ) + .await; + + let warnings = Warnings::default(); + let effects = { + let _guard = warnings.capture(); + dispatch_checked(&mut config, &tdk, &m).await + }; + + assert!( + status(&config, &vtc) == CommunityStatus::Rejected, + "{:?}", + status(&config, &vtc) + ); + assert_eq!(effects.inactivated.len(), 1); + let logged = warnings.text(); + assert!(!logged.contains("replay"), "{logged}"); +} + +/// VTI-NTF-054: a notification addressed to one of our personas is handed over exactly as +/// received, whoever sent it — this client applies no acceptance policy — and one addressed +/// to a DID this account holds no persona for is not. +#[tokio::test] +async fn vti_ntf_054_a_notification_for_a_persona_is_handed_over_as_received() { + let tdk = test_tdk().await; + let mut config = with_identity(pending_config("did:key:z6MkSomeCommunity")); + let stranger = "did:key:z6MkNotACommunityWeKnow"; + let document = serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": crate::notifications::NOTIFY_TYPE, + "issuer": stranger, + "recipient": PERSONA, + "issuedAt": "2026-11-03T00:00:00.000000Z", + "payload": { "notification": { "notificationId": "n-1" } }, + "proof": { "type": "DataIntegrityProof", "proofValue": "z5opaque" } + }); + let wrapped = vta_sdk::tsp_binding::wrap_envelope(&serde_json::to_vec(&document).unwrap()); + let m = crate::didcomm::deliver_tsp_document(&wrapped, stranger, PERSONA) + .expect("a notification passes the inbound gate"); + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!( + effects.deferred.is_none(), + "nothing is checked before the hand-over" + ); + assert_eq!(effects.notifications.len(), 1); + let received = &effects.notifications[0]; + assert_eq!(received.document, document, "exactly as received"); + assert_eq!(received.sender, stranger); + assert_eq!(received.addressee, PERSONA); + + let mut elsewhere = document.clone(); + elsewhere["recipient"] = serde_json::json!("did:key:z6MkNotOurs"); + elsewhere["id"] = serde_json::json!(format!("urn:uuid:{}", uuid::Uuid::new_v4())); + let wrapped = vta_sdk::tsp_binding::wrap_envelope(&serde_json::to_vec(&elsewhere).unwrap()); + let m = crate::didcomm::deliver_tsp_document(&wrapped, stranger, PERSONA).unwrap(); + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.notifications.is_empty()); +} + +/// An election answer from a community we hold no membership with — one holding an +/// election a notification told us of — is checked and handed over only on a thread we sent +/// it (VTI-NTF-063); on any other it has no standing and is never set aside for a check. +#[tokio::test] +async fn an_election_answer_from_a_non_member_community_needs_a_thread_we_sent() { + let (holder, key) = community_key(); + let tdk = test_tdk().await; + let mut config = with_identity(pending_config("did:key:z6MkTheCommunityWeBelongTo")); + let answer = |thid: String| { + let holder = holder.clone(); + let key = key.clone(); + async move { + let mut document = serde_json::from_value(serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": "https://trusttasks.org/spec/trust-task-error/0.1", + "issuer": holder, + "recipient": PERSONA, + "issuedAt": chrono::Utc::now() + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "threadId": thid, + "payload": { "code": "vtc/elections/show:electionUnknown" }, + })) + .unwrap(); + crate::capabilities::sign_document(&mut document, &key) + .await + .unwrap(); + let wrapped = + vta_sdk::tsp_binding::wrap_envelope(&serde_json::to_vec(&document).unwrap()); + crate::didcomm::deliver_tsp_document(&wrapped, &holder, PERSONA).unwrap() + } + }; + + // Not on a thread we sent it: not even set aside for a check. + let m = answer(format!("urn:uuid:{}", uuid::Uuid::new_v4())).await; + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.deferred.is_none() && effects.elections_replies.is_empty()); + + // On a thread we sent it: checked, then handed to the Elections panel. + let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); + crate::elections::expect_answer(&holder, &thid); + let m = answer(thid.clone()).await; + let effects = dispatch_checked(&mut config, &tdk, &m).await; + assert_eq!(effects.elections_replies.len(), 1); + assert_eq!(effects.elections_replies[0].thid, thid); + crate::elections::answered(&thid); +} + +/// Dispatch `m` as `arrival`, returning the effects. +async fn dispatch( + config: &mut Config, + tdk: &TDK, + m: &Message, + arrival: Arrival<'_>, +) -> InboundEffects { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut effects = InboundEffects::default(); + process_inbound_message( + config, + tdk, + &service, + &mut seen, + m, + m.from.as_deref(), + &mut effects, + arrival, + ) + .await + .unwrap(); + effects +} + +/// The local checks run before anything is queued for a network-bound +/// check: a credential or operational document from a party we hold no +/// membership with, an accept that answers nothing of ours, or a +/// community answer to no question of ours is never set aside (so never +/// resolved or fetched for) — it is handled at once, and refused. +#[tokio::test] +async fn only_what_passes_the_local_checks_is_queued_for_a_check() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = pending_config(&vtc); + let stranger = "did:key:z6MkStranger"; + + // A credential "issued" by a stranger, about itself. + let mut vc = membership_vmc(&vtc, &key).await; + vc["issuer"] = serde_json::json!(stranger); + let effects = dispatch( + &mut config, + &tdk, + &issue(stranger, &key, vc).await, + Arrival::FRESH, + ) + .await; + assert!(effects.deferred.is_none()); + // A credential for someone else, from the community. + let mut vc = membership_vmc(&vtc, &key).await; + vc["credentialSubject"]["id"] = serde_json::json!("did:key:z6MkSomeoneElse"); + let effects = dispatch( + &mut config, + &tdk, + &issue(&vtc, &key, vc).await, + Arrival::FRESH, + ) + .await; + assert!(effects.deferred.is_none()); + assert!(matches!( + status(&config, &vtc), + CommunityStatus::Pending { .. } + )); + + // Operational documents from a stranger. + for typ in [ + MEMBER_REMOVAL_NOTICE_TYPE, + JOIN_REQUEST_STATUS_RESPONSE_TYPE, + vta_sdk::protocols::vetting::VETTING_VETTER_LIST_RESPONSE_TYPE, + ] { + let m = Message::build( + uuid::Uuid::new_v4().to_string(), + typ.to_string(), + serde_json::json!({ "payload": { "did": PERSONA } }), + ) + .from(stranger.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize(); + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.deferred.is_none(), "{typ}"); + } + + // A relationship accept answering no request of ours. + let m = Message::build( + uuid::Uuid::new_v4().to_string(), + crate::protocol_urls::RELATIONSHIP_REQUEST_ACCEPT.to_string(), + serde_json::json!({ "did": stranger }), + ) + .from(stranger.to_string()) + .thid(uuid::Uuid::new_v4().to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize(); + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.deferred.is_none()); +} + +/// A message whose sender has messages queued for a check waits behind +/// them rather than overtaking them, and is handled when its turn comes. +/// +/// The message here has no check of its own, which is what makes it wait +/// as a barrier. (This used `members/request-vmc`, until that became the +/// community's signed document with a check of its own; its ordering is +/// the same, and `a_request_for_our_vmc_waits_for_the_communitys_signature` +/// covers it.) +#[tokio::test] +async fn a_message_behind_a_check_from_its_sender_waits_its_turn() { + let (vtc, _) = community_key(); + let tdk = test_tdk().await; + let mut config = pending_config(&vtc); + let m = Message::build( + uuid::Uuid::new_v4().to_string(), + "https://didcomm.org/trust-ping/2.0/ping".to_string(), + serde_json::json!({ "response_requested": false }), + ) + .from(vtc.clone()) + .to(PERSONA.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize(); + + let busy = |sender: &str| sender == vtc; + let effects = dispatch(&mut config, &tdk, &m, Arrival::New { waiting: &busy }).await; + let deferred = effects.deferred.expect("waits behind its sender's check"); + assert!(deferred.job.is_barrier()); + + // Nobody else is held up. + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + assert!(effects.deferred.is_none()); + + // Back in its turn, it is handled, not set aside again. + let effects = dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(PreVerified::Barrier), + ) + .await; + assert!(effects.deferred.is_none()); +} + +/// `members/request-vmc` makes this client sign and send a credential, so +/// it is the community's signed operational document or nothing: it is set +/// aside for that check, never acted on for the transport sender alone, and +/// a check that did not pass issues no VMC. +#[tokio::test] +async fn a_request_for_our_vmc_waits_for_the_communitys_signature() { + let (vtc, _) = community_key(); + let tdk = test_tdk().await; + let mut config = pending_config(&vtc); + let m = Message::build( + uuid::Uuid::new_v4().to_string(), + MEMBER_REQUEST_VMC_TYPE.to_string(), + serde_json::json!({}), + ) + .from(vtc.clone()) + .to(PERSONA.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize(); + + let effects = dispatch(&mut config, &tdk, &m, Arrival::FRESH).await; + let deferred = effects + .deferred + .expect("a request for our VMC is checked before it is answered"); + assert!( + matches!(deferred.job, VerifyJob::Operational { .. }), + "checked as the community's operational document" + ); + + let effects = dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(deferred.job.unfinished()), + ) + .await; + assert!(effects.deferred.is_none()); + assert!( + config + .account + .memberships_for(&vtc) + .iter() + .all(|m| m.member_vmc.is_none()), + "an unverified request issues nothing" + ); +} + +/// A message kept across a restart is queued for its check again — past +/// the age gate it already passed. One that no longer passes the local +/// checks is not dropped on restore: it waits its turn, and its handler +/// refuses it then. +#[tokio::test] +async fn a_restored_message_is_queued_again() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = pending_config(&vtc); + let mut m = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; + // Older than the replay window allows a fresh message. + m.created_time = Some(1); + assert!( + dispatch(&mut config, &tdk, &m, Arrival::FRESH) + .await + .deferred + .is_none(), + "a fresh arrival that old is dropped" + ); + let effects = dispatch(&mut config, &tdk, &m, Arrival::Restored).await; + assert!(matches!( + effects.deferred.map(|d| d.job), + Some(VerifyJob::Credential { .. }) + )); + + // The membership went meanwhile: it waits as a barrier, and on its + // turn it is refused — nothing is applied. + config.account = Default::default(); + let deferred = dispatch(&mut config, &tdk, &m, Arrival::Restored) + .await + .deferred + .expect("set aside to wait its turn"); + assert!(deferred.job.is_barrier()); + let effects = dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(PreVerified::Barrier), + ) + .await; + assert!(effects.deferred.is_none()); + assert!(config.account.memberships_for(&vtc).is_empty()); +} + +/// A message that waited as a barrier is triaged again on its turn: if it +/// now needs a check (its sender became a community we hold a record +/// with meanwhile), it is set aside for that check rather than refused as +/// unchecked. +#[tokio::test] +async fn a_barrier_is_triaged_again_on_its_turn() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let m = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; + // No record with the community yet: no check, and it waits behind + // its sender's queued work. + let mut config = pending_config(&vtc); + let persona_only = { + let mut c = pending_config(&vtc); + c.account.communities.clear(); + c + }; + let mut early = persona_only; + let busy = |sender: &str| sender == vtc; + let deferred = dispatch(&mut early, &tdk, &m, Arrival::New { waiting: &busy }) + .await + .deferred + .expect("waits its turn"); + assert!(deferred.job.is_barrier()); + // By its turn the join is Pending: it now needs its check. + let again = dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(PreVerified::Barrier), + ) + .await + .deferred + .expect("set aside for the check it now needs"); + assert!(matches!(again.job, VerifyJob::Credential { .. })); +} + +/// A check that did not finish is a refusal: the credential is not stored. +#[tokio::test] +async fn an_unfinished_check_refuses() { + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = pending_config(&vtc); + let m = issue(&vtc, &key, membership_vmc(&vtc, &key).await).await; + let deferred = dispatch(&mut config, &tdk, &m, Arrival::FRESH) + .await + .deferred + .unwrap(); + let pre = deferred.job.unfinished(); + dispatch( + &mut config, + &tdk, + &deferred.message, + Arrival::Returning(pre), + ) + .await; + assert!(matches!( + status(&config, &vtc), + CommunityStatus::Pending { .. } + )); +} + +/// Every peer-message handler acts only as the sender the transport +/// authenticated. A message whose `from` is not backed by its sealing key +/// — none bound, or another DID — creates no task, removes none, changes no +/// relationship and hands nothing over. +mod sender_binding { + use super::*; + use crate::protocol_urls; + use crate::relationships::Relationship; + + const ALICE: &str = "did:key:z6MkAlicePeerInAnEstablishedRelationship"; + const CAROL: &str = "did:key:z6MkCarolSomeoneElse"; + + fn config_with(state: RelationshipState, task_id: &str) -> Config { + let mut config = with_identity(pending_config("did:key:z6MkSomeCommunity")); + let alice = Arc::new(ALICE.to_string()); + config.private.relationships.relationships.insert( + Arc::clone(&alice), + Relationship { + task_id: Arc::new(task_id.to_string()), + our_did: Arc::new(PERSONA.to_string()), + remote_did: Arc::clone(&alice), + remote_p_did: alice, + created: chrono::Utc::now(), + state, + our_persona: None, + needs_reestablishment: false, + }, + ); + config + } + + fn message( + typ: &str, + from: Option<&str>, + thid: Option<&str>, + body: serde_json::Value, + ) -> Message { + let mut b = Message::build(uuid::Uuid::new_v4().to_string(), typ.to_string(), body) + .to(PERSONA.to_string()) + .created_time(chrono::Utc::now().timestamp() as u64); + if let Some(from) = from { + b = b.from(from.to_string()); + } + if let Some(thid) = thid { + b = b.thid(thid.to_string()); + } + b.finalize() + } + + async fn dispatch_as( + config: &mut Config, + m: &Message, + authenticated: Option<&str>, + ) -> InboundEffects { + let tdk = test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut effects = InboundEffects::default(); + process_inbound_message( + config, + &tdk, + &service, + &mut seen, + m, + authenticated, + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + effects + } + + /// The two ways a claimed `from` is not the sender: nothing bound, or + /// someone else bound. + const UNBOUND: [Option<&str>; 2] = [None, Some(CAROL)]; + + #[tokio::test] + async fn a_vrc_request_needs_its_sender_authenticated() { + for authenticated in UNBOUND { + let mut config = config_with(RelationshipState::Established, "rel"); + let m = message( + protocol_urls::VRC_REQUEST, + Some(ALICE), + None, + serde_json::json!({ "reason": "please" }), + ); + dispatch_as(&mut config, &m, authenticated).await; + assert!( + config.private.tasks.tasks.is_empty(), + "no inbox task for {authenticated:?}" + ); + } + let mut config = config_with(RelationshipState::Established, "rel"); + let m = message( + protocol_urls::VRC_REQUEST, + Some(ALICE), + None, + serde_json::json!({ "reason": "please" }), + ); + dispatch_as(&mut config, &m, Some(ALICE)).await; + assert_eq!(config.private.tasks.tasks.len(), 1, "authenticated: queued"); + } + + #[tokio::test] + async fn a_vrc_request_rejection_needs_its_sender_authenticated() { + let task = Arc::new(uuid::Uuid::new_v4().to_string()); + let setup = || { + let mut config = config_with(RelationshipState::Established, "rel"); + config.private.tasks.new_task( + &task, + TaskType::VRCRequestOutbound { + remote_p_did: Arc::new(ALICE.to_string()), + }, + ); + config + }; + let reject = || { + message( + protocol_urls::VRC_REJECTED, + Some(ALICE), + Some(&task), + serde_json::json!({ "reason": "no" }), + ) + }; + for authenticated in UNBOUND { + let mut config = setup(); + dispatch_as(&mut config, &reject(), authenticated).await; + assert!(config.private.tasks.get_by_id(&task).is_some()); + } + let mut config = setup(); + dispatch_as(&mut config, &reject(), Some(ALICE)).await; + assert!(config.private.tasks.get_by_id(&task).is_none()); + } + + /// A pong closes only our ping, answered by the party pinged — never a + /// task named by thread id alone, and never for a pong with no + /// authenticated sender. + #[tokio::test] + async fn a_pong_closes_only_our_ping_to_its_authenticated_sender() { + let ping = Arc::new(uuid::Uuid::new_v4().to_string()); + let other = Arc::new(uuid::Uuid::new_v4().to_string()); + let setup = || { + let mut config = config_with(RelationshipState::Established, "rel"); + config.private.tasks.new_task( + &ping, + TaskType::TrustPing { + from: Arc::new(PERSONA.to_string()), + to: Arc::new(ALICE.to_string()), + remote_p_did: Arc::new(ALICE.to_string()), + }, + ); + config.private.tasks.new_task( + &other, + TaskType::VRCRequestOutbound { + remote_p_did: Arc::new(ALICE.to_string()), + }, + ); + config + }; + let pong = |from: Option<&str>, thid: &str| { + message( + protocol_urls::TRUST_PONG, + from, + Some(thid), + serde_json::json!({}), + ) + }; + + // No `from`, nothing authenticated: the old shortcut is gone. + let mut config = setup(); + dispatch_as(&mut config, &pong(None, &ping), None).await; + assert!(config.private.tasks.get_by_id(&ping).is_some()); + + for authenticated in UNBOUND { + let mut config = setup(); + dispatch_as(&mut config, &pong(Some(ALICE), &ping), authenticated).await; + assert!(config.private.tasks.get_by_id(&ping).is_some()); + } + + // Authenticated, but the thread names a task that is not a ping. + let mut config = setup(); + dispatch_as(&mut config, &pong(Some(ALICE), &other), Some(ALICE)).await; + assert!(config.private.tasks.get_by_id(&other).is_some()); + + // Authenticated as someone we did not ping. + let mut config = setup(); + dispatch_as(&mut config, &pong(Some(CAROL), &ping), Some(CAROL)).await; + assert!(config.private.tasks.get_by_id(&ping).is_some()); + + let mut config = setup(); + dispatch_as(&mut config, &pong(Some(ALICE), &ping), Some(ALICE)).await; + assert!(config.private.tasks.get_by_id(&ping).is_none()); + assert!(config.private.tasks.get_by_id(&other).is_some()); + } + + #[tokio::test] + async fn a_relationship_rejection_needs_its_sender_authenticated() { + let thid = uuid::Uuid::new_v4().to_string(); + let reject = || { + message( + protocol_urls::RELATIONSHIP_REQUEST_REJECT, + Some(ALICE), + Some(&thid), + serde_json::json!({ "reason": "no" }), + ) + }; + for authenticated in UNBOUND { + let mut config = config_with(RelationshipState::RequestSent, &thid); + dispatch_as(&mut config, &reject(), authenticated).await; + assert_eq!(config.private.relationships.relationships.len(), 1); + } + let mut config = config_with(RelationshipState::RequestSent, &thid); + dispatch_as(&mut config, &reject(), Some(ALICE)).await; + assert!(config.private.relationships.relationships.is_empty()); + } + + #[tokio::test] + async fn a_relationship_finalize_needs_its_sender_authenticated() { + let thid = uuid::Uuid::new_v4().to_string(); + let finalize = || { + message( + protocol_urls::RELATIONSHIP_REQUEST_FINALIZE, + Some(ALICE), + Some(&thid), + serde_json::json!({}), + ) + }; + let state = |config: &Config| { + config + .private + .relationships + .relationships + .values() + .next() + .map(|r| r.state.clone()) + }; + for authenticated in UNBOUND { + let mut config = config_with(RelationshipState::RequestAccepted, &thid); + dispatch_as(&mut config, &finalize(), authenticated).await; + assert_eq!(state(&config), Some(RelationshipState::RequestAccepted)); + } + let mut config = config_with(RelationshipState::RequestAccepted, &thid); + dispatch_as(&mut config, &finalize(), Some(ALICE)).await; + assert_eq!(state(&config), Some(RelationshipState::Established)); + } + + /// A message dropped for its sender does not enter the replay cache, + /// so the authenticated message with the same id is still handled. + #[tokio::test] + async fn an_unbound_message_does_not_use_up_its_id() { + let tdk = test_tdk().await; + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + crate::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut config = config_with(RelationshipState::Established, "rel"); + let m = message( + protocol_urls::VRC_REQUEST, + Some(ALICE), + None, + serde_json::json!({ "reason": "please" }), + ); + for authenticated in [None, Some(ALICE)] { + let mut effects = InboundEffects::default(); + process_inbound_message( + &mut config, + &tdk, + &service, + &mut seen, + &m, + authenticated, + &mut effects, + Arrival::FRESH, + ) + .await + .unwrap(); + } + assert_eq!(config.private.tasks.tasks.len(), 1); + } + + #[tokio::test] + async fn a_trust_ping_task_needs_its_sender_authenticated() { + for authenticated in UNBOUND { + let mut config = config_with(RelationshipState::Established, "rel"); + let m = message( + protocol_urls::TRUST_PING, + Some(ALICE), + None, + serde_json::json!({}), + ); + dispatch_as(&mut config, &m, authenticated).await; + assert!(config.private.tasks.tasks.is_empty()); + } + } + + /// A notification is handed over and answered with a receipt to its + /// sender; with no authenticated sender there is nobody to answer. + #[tokio::test] + async fn a_notification_needs_its_sender_authenticated() { + let document = || { + serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": crate::notifications::NOTIFY_TYPE, + "issuer": ALICE, + "recipient": PERSONA, + "issuedAt": "2026-11-03T00:00:00.000000Z", + "payload": { "notification": { "notificationId": "n-1" } }, + }) + }; + for authenticated in UNBOUND { + let mut config = config_with(RelationshipState::Established, "rel"); + let m = message( + crate::notifications::NOTIFY_TYPE, + Some(ALICE), + None, + document(), + ); + let effects = dispatch_as(&mut config, &m, authenticated).await; + assert!(effects.notifications.is_empty(), "{authenticated:?}"); + } + let mut config = config_with(RelationshipState::Established, "rel"); + let m = message( + crate::notifications::NOTIFY_TYPE, + Some(ALICE), + None, + document(), + ); + let effects = dispatch_as(&mut config, &m, Some(ALICE)).await; + assert_eq!(effects.notifications.len(), 1); + assert_eq!(effects.notifications[0].sender, ALICE); + } +} diff --git a/openvtc-core/src/join.rs b/openvtc-core/src/join.rs index 5d41c5d4..71cc0300 100644 --- a/openvtc-core/src/join.rs +++ b/openvtc-core/src/join.rs @@ -31,6 +31,9 @@ use vta_sdk::protocols::join_requests::{ use crate::errors::OpenVTCError; +pub mod sequence; +pub mod status_poll; + /// Ceiling on a join submit's serialized body, guarded before it goes out. /// /// Mirrors the inbound `MAX_MESSAGE_BODY_SIZE` (1 MiB): a submit larger than the diff --git a/openvtc-core/src/join/sequence.rs b/openvtc-core/src/join/sequence.rs new file mode 100644 index 00000000..bda2b9d7 --- /dev/null +++ b/openvtc-core/src/join/sequence.rs @@ -0,0 +1,392 @@ +//! The join sequence's protocol steps, shared by the TUI's join flow and the +//! headless `openvtc community join`. +//! +//! Each step here touches only [`Config`] and the network; what the person is +//! shown about it (the TUI's join page, the headless command's progress lines) +//! stays with the caller, which applies the step's result. Moved from the CLI +//! (issue #530). The rest of the sequence — minting a persona, choosing and +//! presenting an invitation, answering a community's questions — still lives +//! in the CLI's `join_flow` and moves with the join-flow rework (#529). + +use std::time::Duration; + +use affinidi_tdk::TDK; +use chrono::Utc; +use tracing::{debug, warn}; +use uuid::Uuid; + +use crate::config::{ + Config, PeerTransports, + account::{CommunityRecord, PersonaId}, + community_context, + context_path::build_sub_context_id, +}; +use crate::didcomm::MessagingTransport; +use crate::errors::OpenVTCError; +use crate::health::ProbePolicy; +use crate::vetting::discover; + +/// How long resolving a community's DID may take before its endpoint is given +/// up on. Bounded by rule R1.2, like every other outbound step here. +pub const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10); + +/// Why a community could not be asked what it requires. +/// +/// The distinction is not cosmetic: it decides whether "join anyway" is an +/// offer at all. A community that resolves but will not answer is one you can +/// still send a request to, and its moderators will see it. A DID that does not +/// resolve names nothing — there is no document, no endpoint and no mediator +/// route, so a join request addressed to it goes nowhere and waits for an +/// answer nobody can send. Offering the same key for both told someone who had +/// mistyped a DID that joining was "the way forward here" (rule R6.4). +pub enum NotLearned { + /// The identifier did not resolve to a DID document. + Unresolvable(String), + /// It resolved; the manifest could not be read from it. + Unanswered(String), +} + +impl NotLearned { + /// The sentence the page shows. + pub fn reason(&self) -> &str { + match self { + NotLearned::Unresolvable(why) | NotLearned::Unanswered(why) => why, + } + } + + /// Whether there is something at this DID to send a join request to. + pub fn resolvable(&self) -> bool { + matches!(self, NotLearned::Unanswered(_)) + } +} + +/// Read `vtc_did`'s manifest from the REST endpoint it publishes, and learn it. +/// +/// The question a community answers about what it requires is a public read +/// (`vetting-process.md` §6.1), and serving it over HTTPS is what makes it +/// answerable on a first join: no mediator socket, no persona to ask as, and no +/// loop that can hear a reply. `Err` is the sentence the page shows, already +/// distinguishing the ways it can fail — rule R6.4. +/// +/// Mirrors the DIDComm handler +/// ([`crate::vetting::inbound`]): the book learns the manifest, and any +/// application to this community adopts it, so a change to the requirements +/// mid-application is detectable from either route rather than only from one. +pub async fn learn_over_http( + config: &mut Config, + tdk: &TDK, + vtc_did: &str, +) -> Result<(), NotLearned> { + let resolver = tdk.did_resolver(); + let resolved = tokio::time::timeout(RESOLVE_TIMEOUT, resolver.resolve(vtc_did)) + .await + .map_err(|_| { + // A timeout is not "there is nothing there": the resolver may + // simply be slow or the host briefly down, and the DID is still + // worth sending to. + NotLearned::Unanswered(format!( + "it could not be resolved within {} seconds", + RESOLVE_TIMEOUT.as_secs() + )) + })? + .map_err(|e| NotLearned::Unresolvable(format!("it could not be resolved ({e})")))?; + let doc = serde_json::to_value(&resolved.doc) + .map_err(|e| NotLearned::Unanswered(format!("its DID document could not be read ({e})")))?; + let discover::FetchedManifest { + manifest, + raw, + protocol, + meta, + } = discover::fetch_manifest(&doc, vtc_did, resolver, ProbePolicy::PublicOnly) + .await + .map_err(|e| NotLearned::Unanswered(e.to_string()))?; + + let now = Utc::now(); + let before = config.private.vetting.vetter_mode(vtc_did).mode(); + config + .private + .vetting + .learn_manifest_in(vtc_did, &manifest, Some(protocol), &meta, now); + // What its directory would show about the person joining, shown on the + // review page before anything is sent (`manifest/0.4`). + config.private.vetting.learn_directory(vtc_did, &raw); + // Whether it vets by PCS ZKP now, read from the same answer — so the badges, the vetter + // desk and this join agree on one reading, whichever route brought it. + config + .private + .vetting + .learn_mode(vtc_did, &raw, before, now); + // The document is in hand already, so how the community signs is free to + // learn — and before joining it is the only evidence there is. + note_signing(config, vtc_did, &resolved.doc); + let book = &mut config.private.vetting; + for application in book + .applications + .iter_mut() + .filter(|a| a.community == vtc_did) + { + if let Err(e) = application.adopt_manifest(&manifest, &raw) { + // Not debug-only: an application that cannot take up its criterion gathers + // evidence for the wrong one. The vetting page re-reads it (m) and says why. + warn!(community = %vtc_did, error = %e, "community manifest not adopted"); + } + } + Ok(()) +} + +/// Record how `vtc_did` signs, from its resolved DID document: whether it lists +/// a post-quantum signing key. The book keeps it only for a community it +/// already knows, so this follows learning the manifest. +pub fn note_signing(config: &mut Config, vtc_did: &str, doc: &affinidi_tdk::did_common::Document) { + config + .private + .vetting + .note_post_quantum_key(vtc_did, crate::proof_check::publishes_post_quantum_key(doc)); +} + +/// Learn how `vtc_did` signs when the book does not know yet, by resolving its +/// DID document — bounded by [`RESOLVE_TIMEOUT`] (rule R1.2). +/// +/// Reading the manifest over HTTP resolves the document anyway and records +/// this on the way; a manifest that arrived over DIDComm, or one already in the +/// book from an earlier visit, never did, so the vetting page said "Signing: +/// not checked" about a community whose document resolves perfectly well. +/// Best-effort: a resolve that fails or runs out of time leaves it unknown, +/// and the page says it could not be checked rather than guessing either way. +pub async fn learn_signing(config: &mut Config, tdk: &TDK, vtc_did: &str) { + if config.private.vetting.post_quantum_key(vtc_did).is_some() { + return; + } + match tokio::time::timeout(RESOLVE_TIMEOUT, tdk.did_resolver().resolve(vtc_did)).await { + Ok(Ok(resolved)) => note_signing(config, vtc_did, &resolved.doc), + Ok(Err(e)) => { + debug!(community = %vtc_did, error = %e, "community DID not resolved; signing not checked"); + } + Err(_) => debug!( + community = %vtc_did, + timeout_secs = RESOLVE_TIMEOUT.as_secs(), + "community DID resolve timed out; signing not checked" + ), + } +} + +/// Whether `context_id` is already claimed: by a membership, a persona's keys, +/// or a vetting application's face. +pub fn context_taken(config: &Config, context_id: &str) -> bool { + community_context::context_claimed( + &config.account, + &config.private.vetting.applications, + context_id, + ) +} + +/// The new sub-context to suggest for `vtc_did`: named after the community, +/// and not yet claimed. +pub fn suggested_context(config: &Config, vtc_did: &str) -> String { + let top = &config.account.top_context_id; + let taken = |id: &str| context_taken(config, id); + build_sub_context_id(top, config.agent_name_for(vtc_did), vtc_did, taken) + .or_else(|_| build_sub_context_id(top, Some("community"), vtc_did, taken)) + .unwrap_or_else(|_| top.clone()) +} + +/// Build the `Pending` [`CommunityRecord`] recorded on a successful submit. +/// +/// Pure decision peeled out of the join sequence's submit step; delegates to +/// [`CommunityRecord::new_pending`] so the record shape stays defined in core. +pub fn pending_record( + vtc_did: String, + display_name: Option, + sub_context_id: String, + persona_id: PersonaId, + request_id: uuid::Uuid, + now: chrono::DateTime, + submit_transport: crate::didcomm::MessagingTransport, +) -> CommunityRecord { + let mut record = CommunityRecord::new_pending( + vtc_did, + display_name, + sub_context_id, + persona_id, + request_id, + now, + ); + // Which transport carried the submit, so an unacknowledged join can name it + // rather than leaving every cause looking alike. + record.submit_transport = Some(submit_transport); + record +} + +/// Remove a persona minted for a join that then failed, from the config only +/// (the caller saves). Shared by the TUI's join and `community join`. +pub fn forget_minted_persona( + config: &mut Config, + persona_id: PersonaId, + keys: Option<&crate::config::PersonaDIDKeys>, + prior_friendly_name: &str, +) { + config.account.personas.remove(&persona_id); + config.identities.remove(&persona_id); + if let Some(keys) = keys { + config.key_info.remove(&keys.signing.secret.id); + config.key_info.remove(&keys.authentication.secret.id); + config.key_info.remove(&keys.decryption.secret.id); + } + // `mint_persona_into` set `public.friendly_name` to the attempted community's + // persona name; restore the pre-mint value so a failed/cancelled join doesn't + // leave the self-display name pointing at a community we never joined. + config.public.friendly_name = prior_friendly_name.to_string(); +} + +/// Something about the chosen transport the person should be told: the +/// submit is not going the way the community prefers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportNote { + /// The community prefers TSP, our mediator does not carry it, and the + /// community also offers DIDComm: the submit goes over DIDComm. + DidcommFallback, + /// The community offers TSP only and our mediator does not advertise it: + /// the submit is attempted over TSP anyway. + TspAttempted, +} + +impl TransportNote { + /// The sentence the join page shows. + #[must_use] + pub fn describe(self) -> &'static str { + match self { + TransportNote::DidcommFallback => { + "Your mediator does not carry TSP — submitting over DIDComm instead." + } + TransportNote::TspAttempted => { + "This community offers TSP only, and your mediator does not \ + advertise it — attempting the submit over TSP anyway." + } + } + } +} + +/// How a join request goes out: over TSP (sealed to the community's TSP +/// mediator) or over DIDComm. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubmitRoute { + /// The community's TSP mediator when the submit goes over TSP. + pub tsp_mediator: Option, + /// What to tell the person about the choice, if anything. + pub note: Option, +} + +impl SubmitRoute { + /// The transport the submit uses. + #[must_use] + pub fn transport(&self) -> MessagingTransport { + if self.tsp_mediator.is_some() { + MessagingTransport::Tsp + } else { + MessagingTransport::DidComm + } + } +} + +/// Settle the wire for a submit to `vtc_did` from a persona whose mediator is +/// `our_mediator`. +/// +/// TSP needs **both** legs: the protocol is the highest-preference one present +/// in *both* parties' documents. A TSP send posts the raw CESR frame to *our* +/// mediator's `/inbound`; the community's advertised mediator is only the hop +/// the routing layer is sealed to. A mediator without its `tsp` feature rejects +/// the frame as an unparseable DIDComm envelope while the error names the +/// *other* mediator, so the question is answered up front. +/// +/// `None` at either end sends over DIDComm, so a community that has not been +/// flipped is unaffected. Where our mediator does not carry TSP (or cannot be +/// asked), DIDComm is preferred when the community offers it; with TSP the +/// community's only transport the submit goes over TSP and the mediator +/// answers — refusing here would only replace one failure with another. +pub async fn choose_submit_route( + vtc_did: &str, + our_mediator: &str, + transports: &PeerTransports, +) -> SubmitRoute { + let didcomm_offered = transports.didcomm_mediator.is_some(); + let Some(peer_mediator) = crate::config::peer_tsp_mediator(vtc_did).await else { + return SubmitRoute { + tsp_mediator: None, + note: None, + }; + }; + let (tsp_mediator, note) = match crate::config::our_mediator_carries_tsp(our_mediator).await { + Some(true) => (Some(peer_mediator), None), + Some(false) if didcomm_offered => (None, Some(TransportNote::DidcommFallback)), + Some(false) => (Some(peer_mediator), Some(TransportNote::TspAttempted)), + // Could not ask. Unknown is not "no": take the community's preference + // where it has an alternative, since DIDComm is the transport that + // worked before TSP existed. + None if didcomm_offered => (None, None), + None => (Some(peer_mediator), None), + }; + SubmitRoute { tsp_mediator, note } +} + +/// Why a join request could not be sent. Each caller words these for its own +/// surface, and they differ in what is undone: nothing was sent in any of them. +#[derive(Debug, thiserror::Error)] +pub enum SubmitError { + /// No messaging (ATM) runtime. + #[error("messaging (ATM) is unavailable")] + NoMessaging, + /// The persona has no runtime messaging identity. + #[error("the persona has no messaging identity")] + NoIdentity, + /// The persona's authentication key, which signs the request, could not be + /// read. + #[error("couldn't read the persona's signing key: {0}")] + Signer(#[source] OpenVTCError), + /// The request was built but the send failed. + #[error("{0}")] + Send(#[source] OpenVTCError), +} + +/// Sign and send the join request for `persona_id` to `vtc_did` along +/// `route`. Returns the request id the pending membership is keyed on. +/// +/// The submit is signed: `join-requests/submit` declares `proof` REQUIRED, and +/// a persona whose key cannot be read cannot apply — better said here than as +/// a refusal from the community. `Ok` means our mediator accepted the frame, +/// not that the community received it (R1.1). +pub async fn submit( + config: &Config, + tdk: &TDK, + persona_id: PersonaId, + vtc_did: &str, + route: &SubmitRoute, + presentation: crate::join::JoinPresentation, +) -> Result { + let atm = tdk.atm.as_ref().ok_or(SubmitError::NoMessaging)?; + let identity = config + .identities + .get(&persona_id) + .ok_or(SubmitError::NoIdentity)?; + let persona_did = identity.persona_did().to_string(); + let profile = identity.profile().clone(); + let mediator = identity.mediator_did.clone().unwrap_or_default(); + let signer = config + .get_persona_keys_for(persona_id, tdk) + .await + .map_err(SubmitError::Signer)? + .authentication + .secret + .clone(); + let applicant = crate::join::Applicant { + atm, + profile: &profile, + persona_did: &persona_did, + signer: &signer, + vtc_did, + mediator_did: &mediator, + tsp_mediator_did: route.tsp_mediator.as_deref(), + }; + crate::join::submit_join_request(&applicant, presentation) + .await + .map_err(SubmitError::Send) +} diff --git a/openvtc/src/state_handler/join_status_poll.rs b/openvtc-core/src/join/status_poll.rs similarity index 94% rename from openvtc/src/state_handler/join_status_poll.rs rename to openvtc-core/src/join/status_poll.rs index ec5100ea..251873b0 100644 --- a/openvtc/src/state_handler/join_status_poll.rs +++ b/openvtc-core/src/join/status_poll.rs @@ -42,16 +42,16 @@ //! process's politeness, and persisting it would let a stale on-disk backoff //! suppress the poll a fresh launch most wants to make. //! -//! [`CommunityRecord::request_id_confirmed`]: openvtc_core::config::account::CommunityRecord::request_id_confirmed +//! [`CommunityRecord::request_id_confirmed`]: crate::config::account::CommunityRecord::request_id_confirmed use std::collections::HashMap; use std::time::{Duration, Instant}; +use crate::config::Config; +use crate::config::account::{PendingPoll, PersonaId, VtcDid}; +use crate::didcomm::MessagingTransport; use affinidi_tdk::TDK; use affinidi_tdk::messaging::ATM; -use openvtc_core::config::Config; -use openvtc_core::config::account::{PendingPoll, PersonaId, VtcDid}; -use openvtc_core::didcomm::MessagingTransport; use tracing::debug; /// Backoff after the n-th consecutive poll of one record: 1, 2, 4, 8 minutes, @@ -61,10 +61,10 @@ const POLL_BACKOFF_BASE: Duration = Duration::from_secs(60); /// Ceiling on the per-record backoff. A join parked for human review can sit for /// days; a poll every quarter of an hour is enough to notice the outcome /// promptly without making this client a load source. -const POLL_BACKOFF_CAP: Duration = Duration::from_secs(900); +pub const POLL_BACKOFF_CAP: Duration = Duration::from_secs(900); /// How many polls one tick may send, across all records. -const MAX_POLLS_PER_TICK: usize = 4; +pub const MAX_POLLS_PER_TICK: usize = 4; /// Polls per record, for the life of the process, that may carry /// `resendCredentials`. The community rate-limits the flag itself, but a reply @@ -96,7 +96,7 @@ fn poll_backoff(attempts: u32) -> Duration { /// Per-record poll pacing for the life of the process. #[derive(Debug, Default)] -pub(crate) struct PollPacer { +pub struct PollPacer { sent: HashMap, } @@ -113,7 +113,7 @@ impl PollPacer { /// capped at [`MAX_POLLS_PER_TICK`]. Marks each returned record as polled, so /// a caller that then fails to send simply retries after the backoff rather /// than hammering an unreachable community. - pub(crate) fn due(&mut self, candidates: Vec, now: Instant) -> Vec { + pub fn due(&mut self, candidates: Vec, now: Instant) -> Vec { // Forget records that are no longer pending (resolved, left, expired), so // a long-lived process doesn't accumulate their pacing entries and a // community re-joined later starts from a clean first poll. @@ -157,7 +157,7 @@ impl PollPacer { /// One poll, resolved against the account so the send needs no `Config` borrow /// and can therefore be moved into a spawned task. -pub(crate) struct Poll { +pub struct Poll { applicant_did: String, /// The persona's signing key: `join-requests/status/0.1` declares `proof` /// REQUIRED, so a poll is a signed document like the submit it chases. @@ -182,7 +182,7 @@ pub(crate) struct Poll { /// A persona whose signing key cannot be read is dropped the same way: the poll /// is a signed document, and an unsigned one would be refused by a community /// enforcing the requirement rather than answered. -pub(crate) async fn build(config: &Config, tdk: &TDK, due: Vec) -> Vec { +pub async fn build(config: &Config, tdk: &TDK, due: Vec) -> Vec { let mut polls = Vec::new(); for record in due { let Some(identity) = config.identities.get(&record.persona_ref) else { @@ -226,17 +226,17 @@ pub(crate) async fn build(config: &Config, tdk: &TDK, due: Vec) -> /// Sequential on purpose. The cap is four per tick, these are sends to /// potentially the same community, and a fan of concurrent sends buys nothing /// against a reply path that is asynchronous either way. -pub(crate) async fn send_all(atm: ATM, polls: Vec) { +pub async fn send_all(atm: ATM, polls: Vec) { for poll in polls { // TSP needs the community's *advertised* TSP mediator, resolved fresh: // it is the hop the routing layer seals to, and a document may have // changed since the submit. let tsp_mediator = if poll.over_tsp { - openvtc_core::config::peer_tsp_mediator(&poll.vtc_did).await + crate::config::peer_tsp_mediator(&poll.vtc_did).await } else { None }; - let route = openvtc_core::join::Applicant { + let route = crate::join::Applicant { atm: &atm, profile: &poll.profile, persona_did: &poll.applicant_did, @@ -245,8 +245,7 @@ pub(crate) async fn send_all(atm: ATM, polls: Vec) { mediator_did: &poll.mediator_did, tsp_mediator_did: tsp_mediator.as_deref(), }; - match openvtc_core::join::poll_join_status(&route, poll.request_id, poll.resend_credentials) - .await + match crate::join::poll_join_status(&route, poll.request_id, poll.resend_credentials).await { Ok(()) => debug!( vtc = %poll.vtc_did, diff --git a/openvtc-core/src/lib.rs b/openvtc-core/src/lib.rs index f54c03aa..1dae3849 100644 --- a/openvtc-core/src/lib.rs +++ b/openvtc-core/src/lib.rs @@ -43,6 +43,7 @@ pub mod git_signing; pub mod git_workspace; pub mod health; pub mod identity; +pub mod inbound; pub mod issued_credential; pub mod join; pub mod logs; @@ -89,6 +90,9 @@ pub mod vta_sync; pub mod vta_task; pub mod witness; +#[cfg(test)] +mod test_support; + /// Packs a DIDComm message with authenticated encryption and forwards it /// through the mediator to the recipient. /// diff --git a/openvtc-core/src/members.rs b/openvtc-core/src/members.rs index 97b86b8c..4ea46043 100644 --- a/openvtc-core/src/members.rs +++ b/openvtc-core/src/members.rs @@ -172,7 +172,7 @@ pub async fn submit_member_vmc( // // The reply type is unchanged either way: `MEMBER_VMC_RESPONSE_TYPE` is // exactly `MEMBER_VMC_TYPE#response`, which is what the spine's - // `success_response` produces, so `message_dispatch`'s arm for it keeps + // `success_response` produces, so `inbound`'s arm for it keeps // matching. let msg_id = Uuid::new_v4(); let document_id = format!("urn:uuid:{msg_id}"); diff --git a/openvtc-core/src/persona/binding.rs b/openvtc-core/src/persona/binding.rs index feb7f1e6..273e34f3 100644 --- a/openvtc-core/src/persona/binding.rs +++ b/openvtc-core/src/persona/binding.rs @@ -168,7 +168,7 @@ pub async fn get( let value = client .persona_binding_get(context_id, persona_did) .await - .map_err(|e| OpenVTCError::Vta(format!("persona binding read failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona binding read failed", &e))?; Ok(BindingSummary { bound: value @@ -297,7 +297,7 @@ pub async fn list(client: &VtaClient, context_id: &str) -> Result let value = client .persona_binding_list(context_id, None, None) .await - .map_err(|e| OpenVTCError::Vta(format!("persona binding list failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona binding list failed", &e))?; let mut rows: Vec = value .get("personas") .and_then(Value::as_array) @@ -335,7 +335,7 @@ pub async fn set_local( client .persona_local_binding_set(context_id, persona_did, profile_id, None, None, None) .await - .map_err(|e| OpenVTCError::Vta(format!("persona local binding write failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona local binding write failed", &e))?; Ok(()) } @@ -394,7 +394,7 @@ pub async fn set( None, ) .await - .map_err(|e| OpenVTCError::Vta(format!("persona binding write failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona binding write failed", &e))?; Ok(()) } diff --git a/openvtc-core/src/persona/claim_types.rs b/openvtc-core/src/persona/claim_types.rs index 51ed3801..ee38ca85 100644 --- a/openvtc-core/src/persona/claim_types.rs +++ b/openvtc-core/src/persona/claim_types.rs @@ -419,9 +419,7 @@ impl Registry { { Ok(value) => Ok(Self::from_wire(&value)), Err(VtaError::UnsupportedTaskType { .. }) => Ok(Self::vendored()), - Err(e) => Err(OpenVTCError::Vta(format!( - "persona claim-types list failed: {e}" - ))), + Err(e) => Err(OpenVTCError::vta("persona claim-types list failed", &e)), } } diff --git a/openvtc-core/src/persona/contacts.rs b/openvtc-core/src/persona/contacts.rs index 4fa0d8f0..b90e3d7c 100644 --- a/openvtc-core/src/persona/contacts.rs +++ b/openvtc-core/src/persona/contacts.rs @@ -132,7 +132,7 @@ pub async fn list( let value = client .persona_contact_list(context_id, None, None, None, None) .await - .map_err(|e| OpenVTCError::Vta(format!("persona contact list failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona contact list failed", &e))?; let mut rows: Vec = value .get("contacts") .and_then(Value::as_array) @@ -152,7 +152,7 @@ pub async fn get( let value = client .persona_contact_get(context_id, contact_id, None, history) .await - .map_err(|e| OpenVTCError::Vta(format!("persona contact get failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona contact get failed", &e))?; let summary = ContactSummary::from_wire(&value); let mut earlier: Vec = value .get("history") @@ -223,7 +223,7 @@ pub async fn put( notes, ) .await - .map_err(|e| OpenVTCError::Vta(format!("persona contact put failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona contact put failed", &e))?; Ok(value .get("contactId") .and_then(Value::as_str) @@ -240,7 +240,7 @@ pub async fn delete( client .persona_contact_delete(context_id, contact_id) .await - .map_err(|e| OpenVTCError::Vta(format!("persona contact delete failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona contact delete failed", &e))?; Ok(()) } diff --git a/openvtc-core/src/persona/correlation.rs b/openvtc-core/src/persona/correlation.rs index a04625fc..8406956c 100644 --- a/openvtc-core/src/persona/correlation.rs +++ b/openvtc-core/src/persona/correlation.rs @@ -194,9 +194,7 @@ pub async fn analyze(client: &VtaClient) -> Result, OpenVTCError> { Ok(value) => value, Err(VtaError::UnsupportedTaskType { .. }) => return Ok(Vec::new()), Err(e) => { - return Err(OpenVTCError::Vta(format!( - "persona correlation analyze failed: {e}" - ))); + return Err(OpenVTCError::vta("persona correlation analyze failed", &e)); } }; diff --git a/openvtc-core/src/persona/disclosure.rs b/openvtc-core/src/persona/disclosure.rs index 37ca70c5..1cd81cfa 100644 --- a/openvtc-core/src/persona/disclosure.rs +++ b/openvtc-core/src/persona/disclosure.rs @@ -225,12 +225,18 @@ pub async fn preview( Some(RCARD_RENDERER), ) .await - .map_err(|e| OpenVTCError::Vta(format!("persona disclosure preview failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona disclosure preview failed", &e))?; let preview_id = value .get("previewId") .and_then(Value::as_str) .filter(|id| !id.is_empty()) - .ok_or_else(|| OpenVTCError::Vta("the disclosure preview carried no previewId".into()))? + .ok_or_else(|| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + "the disclosure preview carried no previewId", + ) + })? .to_string(); Ok(Preview { preview_id, @@ -362,7 +368,7 @@ pub async fn renderers(client: &VtaClient) -> Result, OpenVTCError let value = client .persona_renderers_list() .await - .map_err(|e| OpenVTCError::Vta(format!("persona renderers list failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona renderers list failed", &e))?; let mut rows: Vec = value .get("renderers") .and_then(Value::as_array) @@ -420,7 +426,7 @@ pub async fn history( let value = client .persona_disclosure_history(None, None, None, None, Some(limit), None) .await - .map_err(|e| OpenVTCError::Vta(format!("persona disclosure history failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona disclosure history failed", &e))?; let mut rows: Vec = value .get("disclosures") diff --git a/openvtc-core/src/persona/join_answers.rs b/openvtc-core/src/persona/join_answers.rs index 2f07e5a3..ef467029 100644 --- a/openvtc-core/src/persona/join_answers.rs +++ b/openvtc-core/src/persona/join_answers.rs @@ -90,7 +90,7 @@ pub async fn preview( None, ) .await - .map_err(|e| OpenVTCError::Vta(format!("could not preview the answers: {e}")))?; + .map_err(|e| OpenVTCError::vta("could not preview the answers", &e))?; preview_from_response(&response, asked) } @@ -102,7 +102,13 @@ pub fn preview_from_response( let preview_id = response .get("previewId") .and_then(Value::as_str) - .ok_or_else(|| OpenVTCError::Vta("the preview carried no previewId".into()))? + .ok_or_else(|| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + "the preview carried no previewId", + ) + })? .to_string(); let claims: Vec<(String, Option)> = response .get("claims") @@ -146,11 +152,17 @@ pub async fn present( let response = client .persona_disclosure_present(context_id, preview_id, None, None) .await - .map_err(|e| OpenVTCError::Vta(format!("could not release the answers: {e}")))?; + .map_err(|e| OpenVTCError::vta("could not release the answers", &e))?; let artifact = response .get("artifact") .and_then(Value::as_str) - .ok_or_else(|| OpenVTCError::Vta("the disclosure carried no artifact".into()))?; + .ok_or_else(|| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + "the disclosure carried no artifact", + ) + })?; attributes_from_artifact(artifact) } @@ -162,8 +174,13 @@ pub async fn present( /// whatever the attribute's provenance, and a claim with no value (a /// predicate) is not an answer. pub fn attributes_from_artifact(artifact: &str) -> Result, OpenVTCError> { - let card: Value = serde_json::from_str(artifact) - .map_err(|e| OpenVTCError::Vta(format!("the disclosure artifact is not JSON: {e}")))?; + let card: Value = serde_json::from_str(artifact).map_err(|e| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + format!("the disclosure artifact is not JSON: {e}"), + ) + })?; let Some(claims) = card.get("claims").and_then(Value::as_object) else { return Ok(Vec::new()); }; diff --git a/openvtc-core/src/persona/lifecycle.rs b/openvtc-core/src/persona/lifecycle.rs index 53df342a..284d169a 100644 --- a/openvtc-core/src/persona/lifecycle.rs +++ b/openvtc-core/src/persona/lifecycle.rs @@ -79,7 +79,7 @@ pub async fn compose( let out: crate::specs::persona::profile::compose::v1_1::Response = crate::vta_task::call(client, &payload, PERSONA_TIMEOUT_SECS) .await - .map_err(|e| OpenVTCError::Vta(format!("could not make the face: {e}")))?; + .map_err(|e| OpenVTCError::vta("could not make the face", &e))?; Ok(Composed { profile_id: out.profile_id.to_string(), local: out.scope.to_string() == "local", @@ -94,7 +94,7 @@ pub async fn retire(client: &VtaClient, profile_id: &str) -> Result Result<(), OpenV .persona_profile_reinstate(profile_id, None, None) .await .map(|_| ()) - .map_err(|e| OpenVTCError::Vta(format!("could not reinstate the face: {e}"))) + .map_err(|e| OpenVTCError::vta("could not reinstate the face", &e)) } /// The holder's retired faces — out of every picker, and listed here so one @@ -112,7 +112,7 @@ pub async fn list_retired(client: &VtaClient) -> Result, Ope let value = client .persona_profile_list(None, None, true) .await - .map_err(|e| OpenVTCError::Vta(format!("persona profile list failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona profile list failed", &e))?; let mut faces: Vec = value .get("profiles") .and_then(Value::as_array) @@ -201,7 +201,7 @@ pub async fn history(client: &VtaClient, profile_id: &str) -> Result = None; // Bounded, so a far side that never ends a listing cannot hang the pane. @@ -209,7 +209,7 @@ pub async fn history(client: &VtaClient, profile_id: &str) -> Result = value .get("attributes") @@ -422,16 +422,26 @@ pub async fn reveal( .await { Ok(response) => Ok(Some(PoolAttribute::from_wire( - &serde_json::to_value(&response.attribute) - .map_err(|e| OpenVTCError::Vta(format!("persona attribute get: {e}")))?, - ))), - // `notFound` is the answer to "is it still there", not a failure: the - // pane asks about a row the holder is looking at, and the row can have - // gone since it was drawn. - Err(e) if format!("{e}").contains("notFound") => Ok(None), - Err(e) => Err(OpenVTCError::Vta(format!( - "persona attribute reveal failed: {e}" + &serde_json::to_value(&response.attribute).map_err(|e| { + OpenVTCError::remote( + crate::errors::RemoteService::Vta, + crate::errors::RemoteKind::Contract, + format!("persona attribute get: {e}"), + ) + })?, ))), + // A Trust Task `notFound` rejection is the answer to "is it still + // there", not a failure: the pane asks about a row the holder is + // looking at, and the row can have gone since it was drawn. Only that + // code counts — a bare REST 404/410 (an older VTA without this task, a + // misrouted request) is a failure, and is reported as one. + Err(e) + if e.rejection_code() + .is_some_and(|code| code.contains("notFound")) => + { + Ok(None) + } + Err(e) => Err(OpenVTCError::vta("persona attribute reveal failed", &e)), } } @@ -451,7 +461,7 @@ pub async fn put(client: &VtaClient, draft: AttributeDraft) -> Result Result, OpenVTCErro // one back would undo the holder's decision. .persona_profile_list(None, None, false) .await - .map_err(|e| OpenVTCError::Vta(format!("persona profile list failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona profile list failed", &e))?; let mut profiles: Vec = value .get("profiles") @@ -307,7 +307,7 @@ pub async fn get( let value = client .persona_profile_get(profile_id, resolve) .await - .map_err(|e| OpenVTCError::Vta(format!("persona profile read failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona profile read failed", &e))?; let profile = value.get("profile").unwrap_or(&Value::Null).clone(); let mut detail = ProfileDetail { @@ -394,7 +394,7 @@ pub async fn put( expected_version, ) .await - .map_err(|e| OpenVTCError::Vta(format!("persona profile write failed: {e}")))?; + .map_err(|e| OpenVTCError::vta("persona profile write failed", &e))?; Ok(response .get("profileId") @@ -422,9 +422,7 @@ pub async fn delete( Ok(_) => Ok(Deleted::Done), Err(e) => match worn_by(&e.to_string()) { Some(n) if !unbind => Ok(Deleted::Worn(n)), - _ => Err(OpenVTCError::Vta(format!( - "persona profile delete failed: {e}" - ))), + _ => Err(OpenVTCError::vta("persona profile delete failed", &e)), }, } } diff --git a/openvtc-core/src/persona/world.rs b/openvtc-core/src/persona/world.rs index f2cb39af..732ed14c 100644 --- a/openvtc-core/src/persona/world.rs +++ b/openvtc-core/src/persona/world.rs @@ -330,7 +330,7 @@ pub async fn list(client: &VtaClient) -> Result, OpenVTCError> { { Ok(value) => value, Err(VtaError::UnsupportedTaskType { .. }) => return Ok(Vec::new()), - Err(e) => return Err(OpenVTCError::Vta(format!("persona world list failed: {e}"))), + Err(e) => return Err(OpenVTCError::vta("persona world list failed", &e)), }; worlds.extend( @@ -389,7 +389,7 @@ pub async fn put(client: &VtaClient, draft: WorldDraft) -> Result cursor = Some(c), @@ -348,7 +348,7 @@ pub async fn plan( let dids = client .list_dids_webvh(Some(context_id), None) .await - .map_err(|e| OpenVTCError::Vta(format!("could not list DIDs in {context_id}: {e}")))? + .map_err(|e| OpenVTCError::vta(format!("could not list DIDs in {context_id}"), &e))? .dids; let personas: Vec = dids diff --git a/openvtc-core/src/relationships.rs b/openvtc-core/src/relationships.rs index 286171f2..38e6cae6 100644 --- a/openvtc-core/src/relationships.rs +++ b/openvtc-core/src/relationships.rs @@ -33,6 +33,8 @@ use std::{ time::SystemTime, }; use tracing::{debug, warn}; + +pub mod keys; use uuid::Uuid; // **************************************************************************** diff --git a/openvtc-core/src/relationships/keys.rs b/openvtc-core/src/relationships/keys.rs new file mode 100644 index 00000000..af2207bd --- /dev/null +++ b/openvtc-core/src/relationships/keys.rs @@ -0,0 +1,349 @@ +//! Relationship DIDs (R-DIDs): minting their keys and the did:peer they make. +//! +//! An R-DID's two keys (Ed25519 verification, X25519 key agreement) come from +//! the account's BIP32 seed or from its VTA, depending on the key backend. +//! [`plan_relationship_did`] is the only step that touches [`Config`] (it +//! reserves BIP32 paths), so it runs where `Config` lives; +//! [`RDidPlan::create_io`] does the I/O and returns the `key_info` entries for +//! the caller to record. Moved from the CLI (issue #530). + +use affinidi_tdk::{ + TDK, + affinidi_crypto::ed25519::ed25519_private_to_x25519, + dids::{DID, PeerKeyRole}, + secrets_resolver::{SecretsResolver, secrets::Secret}, +}; +use base64::Engine; +use chrono::Utc; +use ed25519_dalek_bip32::DerivationPath; +use tracing::info; + +use crate::config::{ + Config, KeyBackend, KeyTypes, + secured_config::{KeyInfoConfig, KeySourceMaterial}, +}; +use crate::didcomm::Messaging; +use crate::errors::OpenVTCError; + +/// Derive the BIP32 child at `path` from `root`. +fn derive( + root: &ed25519_dalek_bip32::ExtendedSigningKey, + path: &str, +) -> Result { + let path = path + .parse::() + .map_err(|e| OpenVTCError::BIP32(format!("bad derivation path {path}: {e}")))?; + root.derive(&path) + .map_err(|e| OpenVTCError::BIP32(format!("couldn't derive a relationship key: {e}"))) +} + +/// Loop-thread plan for creating a relationship DID, plus everything the +/// *I/O-only* [`RDidPlan::create_io`] needs to run off the loop without touching +/// `Config`. Building this is the only step that reads/mutates `Config` (the +/// BIP32 path-pointer reservation); the actual key creation (VTA round-trip or +/// BIP32 derivation), did:peer build, and resolver insert all run in the task. +pub enum RDidPlan { + /// BIP32: paths reserved on the loop thread (pointer already advanced); the + /// task derives the keys from a clone of the root. The root is boxed to keep + /// the enum compact (it is the largest field by far). + Bip32 { + root: Box, + v_path: String, + e_path: String, + mediator: String, + }, + /// VTA: the task creates the keys on the always-on admin session (cloned — + /// cheap, shares the connection pool). + Vta { + admin_vta: vta_sdk::client::VtaClient, + mediator: String, + }, +} + +/// The created relationship DID plus the `key_info` entries to record in +/// `Config` on the loop thread. The secrets themselves were already inserted into +/// the (shared, `Arc`-based) TDK resolver inside the task, so they are visible to +/// the loop's TDK; only the `Config` bookkeeping is left for `apply`. +pub struct CreatedRDid { + pub r_did: String, + pub key_info: Vec<(String, KeyInfoConfig)>, +} + +/// Build the [`RDidPlan`] on the loop thread. For BIP32 this reserves two unique +/// derivation paths and advances `path_pointer` (the only `Config` mutation the +/// pre-send step makes); for VTA it just snapshots the owned client/backend. +pub fn plan_relationship_did( + config: &mut Config, + mediator: &str, + admin_vta: Option<&vta_sdk::client::VtaClient>, +) -> Result { + match &config.key_backend { + KeyBackend::Bip32 { seed, .. } => { + // `ExtendedSigningKey` (ed25519-dalek-bip32 0.3) is not `Clone`, so + // reconstruct an owned root from the persisted seed — the same + // derivation the load path uses, and a pure function of the seed, so + // the rebuilt root is identical to the live one. + let root = Box::new( + ed25519_dalek_bip32::ExtendedSigningKey::from_seed( + &base64::prelude::BASE64_URL_SAFE_NO_PAD + .decode(secrecy::ExposeSecret::expose_secret(seed)) + .map_err(|e| { + OpenVTCError::BIP32(format!("couldn't decode BIP32 seed: {e}")) + })?, + ) + .map_err(|e| OpenVTCError::BIP32(format!("couldn't rebuild BIP32 root: {e}")))?, + ); + let v_path = format!("m/3'/1'/1'/{}'", config.private.relationships.path_pointer); + config.private.relationships.path_pointer += 1; + let e_path = format!("m/3'/1'/1'/{}'", config.private.relationships.path_pointer); + config.private.relationships.path_pointer += 1; + Ok(RDidPlan::Bip32 { + root, + v_path, + e_path, + mediator: mediator.to_string(), + }) + } + KeyBackend::Vta { .. } => { + // The runtime always holds an admin VTA session for a VTA backend + // (built at startup). If it doesn't, R-DID creation can't proceed — + // surface that up front rather than parking the loop to open one. + let admin_vta = admin_vta.cloned().ok_or_else(|| { + OpenVTCError::Vta( + "no VTA session available to create a relationship DID".to_string(), + ) + })?; + Ok(RDidPlan::Vta { + admin_vta, + mediator: mediator.to_string(), + }) + } + } +} + +impl RDidPlan { + fn mediator(&self) -> &str { + match self { + RDidPlan::Bip32 { mediator, .. } | RDidPlan::Vta { mediator, .. } => mediator, + } + } + + /// I/O-only relationship-DID creation, run inside the spawned task. Creates + /// the keys (BIP32 derivation or VTA round-trip), builds the did:peer, + /// registers the secrets in the shared TDK resolver, **and registers the new + /// R-DID listener** (built from the just-minted secrets — no resolver lookup, + /// no `Config`). Returns the `Config` `key_info` entries for the loop thread + /// to record. Never touches `Config`. + /// + /// `service` + `remote_p_did` are only used to register the post-establishment + /// R-DID listener, exactly as the old inline path did right after key creation. + pub async fn create_io( + self, + tdk: &TDK, + service: &Messaging, + remote_p_did: &str, + ) -> Result { + let mediator = self.mediator().to_string(); + // Mint the keys and capture each one's *source material* (BIP32 path or + // VTA key id) in verification-then-encryption order. Note we do NOT record + // the secret ids yet: a freshly generated `Secret` carries a placeholder id + // (`generate_ed25519` → a random base64 string, `generate_x25519` → + // `"x25519"`), and `generate_did_peer_from_secrets` below rewrites those + // ids to the did:peer verification-method ids. `key_info` must be built + // from the *rewritten* ids — see the comment on that step. + let (mut v_secret, mut e_secret, v_source, e_source) = match self { + RDidPlan::Bip32 { + root, + v_path, + e_path, + .. + } => { + let v_key = derive(&root, &v_path)?; + let e_key = derive(&root, &e_path)?; + let v_secret = Secret::generate_ed25519(None, Some(v_key.signing_key.as_bytes())); + let e_secret = Secret::generate_x25519( + None, + Some(&ed25519_private_to_x25519(e_key.signing_key.as_bytes())), + ) + .map_err(|e| { + OpenVTCError::Secret(format!("couldn't build the encryption key: {e}")) + })?; + // NOTE: v_key/e_key hold BIP32-derived signing-key bytes on the + // stack; ed25519-dalek-bip32 does not Zeroize, a known limitation. + drop(v_key); + drop(e_key); + ( + v_secret, + e_secret, + KeySourceMaterial::Derived { path: v_path }, + KeySourceMaterial::Derived { path: e_path }, + ) + } + RDidPlan::Vta { admin_vta, .. } => { + let (v_secret, e_secret, sign_key_id, enc_key_id) = + create_relationship_keys(&admin_vta).await?; + ( + v_secret, + e_secret, + KeySourceMaterial::VtaManaged { + key_id: sign_key_id, + }, + KeySourceMaterial::VtaManaged { key_id: enc_key_id }, + ) + } + }; + + // Build the did:peer from the secrets. This REWRITES each secret's `id` to + // the did:peer verification-method id (`{did}#key-1` for verification, + // `{did}#key-2` for the key-agreement key). + let mut keys = vec![ + (PeerKeyRole::Verification, &mut v_secret), + (PeerKeyRole::Encryption, &mut e_secret), + ]; + let r_did = DID::generate_did_peer_from_secrets(&mut keys, Some(mediator.clone())) + .map_err(|e| OpenVTCError::Secret(format!("Failed to create relationship DID: {e}")))?; + drop(keys); + + // Record `key_info` under the FINAL did:peer-relative secret ids (set by + // the call above), pairing each with its source material. This MUST run + // after the did:peer mint: the reload path (`Relationships:: + // generate_profiles`) only re-registers a stored secret when its id + // `starts_with` the R-DID, and packs DIDComm as the R-DID using the + // `#key-1`/`#key-2` verification-method ids. Persisting the pre-mint + // placeholder ids (the historical bug) left the reloaded R-DID with no + // usable key-agreement secret, so mediator authentication looped forever + // ("sender has no usable key agreement key"). + let key_info: Vec<(String, KeyInfoConfig)> = vec![ + ( + v_secret.id.clone(), + KeyInfoConfig { + path: v_source, + create_time: Utc::now(), + purpose: KeyTypes::RelationshipVerification, + }, + ), + ( + e_secret.id.clone(), + KeyInfoConfig { + path: e_source, + create_time: Utc::now(), + purpose: KeyTypes::RelationshipEncryption, + }, + ), + ]; + + // Register the new R-DID listener from the just-minted secrets (the old + // inline path did this right after key creation; failure is non-fatal). + let listener_config = crate::didcomm::relationship_listener_config_from_secrets( + &r_did, + remote_p_did, + &mediator, + vec![v_secret.clone(), e_secret.clone()], + ); + if let Err(e) = crate::didcomm::add_listener(service, &listener_config).await { + tracing::warn!(did = %r_did, error = %e, "failed to add R-DID listener"); + } + + // Register the secrets in the shared TDK resolver (visible to the loop's + // TDK — same `Arc`-backed resolver). + tdk.get_shared_state() + .secrets_resolver() + .insert(v_secret) + .await; + tdk.get_shared_state() + .secrets_resolver() + .insert(e_secret) + .await; + + Ok(CreatedRDid { r_did, key_info }) + } +} + +/// Create a relationship's signing (Ed25519) + encryption (X25519) keys via the +/// VTA and fetch their secrets, on the given (admin) session. Returns +/// `(v_secret, e_secret, signing_key_id, encryption_key_id)`. +async fn create_relationship_keys( + client: &vta_sdk::client::VtaClient, +) -> Result<(Secret, Secret, String, String), OpenVTCError> { + use vta_sdk::client::CreateKeyRequest; + use vta_sdk::keys::KeyType; + + info!("creating Ed25519 signing key via VTA..."); + let sign_resp = crate::vta_task::retry("create relationship signing key", || { + client.create_key(CreateKeyRequest { + // Absent is today's behaviour. `Some(true)` mints a + // non-extractable key that cannot be recovered from + // the mnemonic or any backup — never a default. + internal: None, + key_type: KeyType::Ed25519, + derivation_path: None, + key_id: None, + mnemonic: None, + label: Some("relationship-signing".to_string()), + context_id: None, + }) + }) + .await + .map_err(|e| OpenVTCError::vta("Failed to create signing key", &e))?; + let sign_secret_resp = crate::vta_task::retry("get relationship signing key secret", || { + client.get_key_secret(&sign_resp.key_id) + }) + .await + .map_err(|e| OpenVTCError::vta("Failed to get signing key secret", &e))?; + let mut v_secret = vta_sdk::did_key::secret_from_key_response(&sign_secret_resp) + .map_err(|e| OpenVTCError::Secret(format!("{e:?}")))?; + v_secret.id = v_secret + .get_public_keymultibase() + .map_err(|e| OpenVTCError::Secret(e.to_string()))?; + + info!("creating X25519 encryption key via VTA..."); + let enc_resp = crate::vta_task::retry("create relationship encryption key", || { + client.create_key(CreateKeyRequest { + // Absent is today's behaviour. `Some(true)` mints a + // non-extractable key that cannot be recovered from + // the mnemonic or any backup — never a default. + internal: None, + key_type: KeyType::X25519, + derivation_path: None, + key_id: None, + mnemonic: None, + label: Some("relationship-encryption".to_string()), + context_id: None, + }) + }) + .await + .map_err(|e| OpenVTCError::vta("Failed to create encryption key", &e))?; + let enc_secret_resp = crate::vta_task::retry("get relationship encryption key secret", || { + client.get_key_secret(&enc_resp.key_id) + }) + .await + .map_err(|e| OpenVTCError::vta("Failed to get encryption key secret", &e))?; + let mut e_secret = vta_sdk::did_key::secret_from_key_response(&enc_secret_resp) + .map_err(|e| OpenVTCError::Secret(format!("{e:?}")))?; + e_secret.id = e_secret + .get_public_keymultibase() + .map_err(|e| OpenVTCError::Secret(e.to_string()))?; + + Ok((v_secret, e_secret, sign_resp.key_id, enc_resp.key_id)) +} + +/// The key that proves control of `our_did` for a handshake: the persona's own +/// authentication key when `our_did` is the persona, else the R-DID's signing +/// key from the TDK's resolver. +pub async fn relationship_did_signer( + tdk: &TDK, + our_did: &str, + persona_did: &str, + persona_auth: &Secret, +) -> Result { + if our_did == persona_did { + return Ok(persona_auth.clone()); + } + crate::relationships::relationship_signing_secret(tdk, our_did) + .await + .ok_or_else(|| { + OpenVTCError::MissingSecretKeyMaterial(format!( + "the relationship DID's signing key is not loaded ({our_did})" + )) + }) +} diff --git a/openvtc-core/src/test_support.rs b/openvtc-core/src/test_support.rs new file mode 100644 index 00000000..c5c3d755 --- /dev/null +++ b/openvtc-core/src/test_support.rs @@ -0,0 +1,45 @@ +//! Shared fixtures for this crate's own unit tests. + +use crate::config::{ + Config, KeyBackend, account::Account, protected_config::ProtectedConfig, + public_config::PublicConfig, secured_config::ProtectionMethod, +}; + +/// An offline TDK: no environment file, no network, no mediator. +pub(crate) async fn test_tdk() -> affinidi_tdk::TDK { + affinidi_tdk::TDK::new( + affinidi_tdk::common::config::TDKConfig::builder() + .with_load_environment(false) + .build() + .expect("TDK config builds"), + None, + ) + .await + .expect("TDK builds offline") +} + +/// A minimal BIP32-backed [`Config`] with no personas, identities or +/// memberships. +pub(crate) fn test_config() -> Config { + Config { + protected_key: None, + integrity: Default::default(), + public: PublicConfig::default(), + private: ProtectedConfig::default(), + key_backend: KeyBackend::Bip32 { + root: ed25519_dalek_bip32::ExtendedSigningKey::from_seed(&[7u8; 32]).unwrap(), + seed: secrecy::SecretString::new("seed".into()), + }, + key_info: std::collections::HashMap::new(), + protection_method: ProtectionMethod::default(), + #[cfg(feature = "openpgp-card")] + token_admin_pin: None, + #[cfg(feature = "openpgp-card")] + token_user_pin: secrecy::SecretString::new("".into()), + unlock_code: None, + account: Account::default(), + identities: std::collections::BTreeMap::new(), + active_persona: None, + runtime_trust_overrides: None, + } +} diff --git a/openvtc-core/src/vetting/discover.rs b/openvtc-core/src/vetting/discover.rs index d482c41d..f8a31b73 100644 --- a/openvtc-core/src/vetting/discover.rs +++ b/openvtc-core/src/vetting/discover.rs @@ -95,6 +95,30 @@ pub enum DiscoverError { WrongSigner { proven: String }, } +impl DiscoverError { + /// The remote-failure kind, for a caller that reports it by class + /// ([`crate::errors::ErrorClass`]). `None` for [`Self::NoEndpoint`] and + /// [`Self::Blocked`]: nothing remote failed, there was nothing (usable) + /// to ask. + #[must_use] + pub fn kind(&self) -> Option { + use crate::errors::RemoteKind; + match self { + Self::NoEndpoint | Self::Blocked(_) => None, + Self::Unreachable(_) => Some(RemoteKind::Unreachable), + Self::Refused { status, code, .. } => Some(match code { + Some(code) => RemoteKind::from_refusal_code(code), + None => RemoteKind::from_http_status(*status), + }), + Self::Unreadable(_) => Some(RemoteKind::Contract), + // It answered, but not with a document this client can accept as + // the community's: a mismatch to report, not a credential of ours + // refused. + Self::Unproven(_) | Self::WrongSigner { .. } => Some(RemoteKind::Contract), + } + } +} + impl std::fmt::Display for DiscoverError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { diff --git a/openvtc-core/src/vta_task.rs b/openvtc-core/src/vta_task.rs index 203be764..10e712f7 100644 --- a/openvtc-core/src/vta_task.rs +++ b/openvtc-core/src/vta_task.rs @@ -34,3 +34,136 @@ pub fn acl_subject_absent(err: &VtaError) -> bool { matches!(err, VtaError::NotFound(_)) || err.rejection_code() == Some("acl/revoke:subjectNotPresent") } + +/// Max attempts for a single VTA round-trip before giving up (1 try + 2 retries). +pub const MAX_ATTEMPTS: usize = 3; + +/// Base back-off between retries; doubles each attempt (0.5s, then 1s). Short, +/// because the dominant retryable fault is a stale DIDComm socket that ATM +/// re-establishes almost immediately — we just need a beat before re-sending. +const RETRY_BASE: std::time::Duration = std::time::Duration::from_millis(500); + +/// True for errors a retry might clear: transport/timeout faults where the +/// request most likely never reached the VTA (or its reply never came back). +/// +/// The motivating case is a stale always-on DIDComm session — the mediator +/// dropped the idle WebSocket, the first `send_and_wait` packed into a dead +/// socket and timed out, but ATM auto-reconnects underneath, so the *next* send +/// lands on a live socket and succeeds. REST `Network` and 5xx `Server` errors +/// are transient the same way. Deterministic faults (validation, conflict, +/// not-found, auth, gone) are never retried — re-sending can't change them. +#[must_use] +pub fn retryable(e: &VtaError) -> bool { + matches!( + e, + VtaError::DidcommTransport(_) | VtaError::Network(_) | VtaError::Server { .. } + ) +} + +/// Run a single VTA round-trip with bounded retry on transient transport faults +/// (see [`retryable`]). `op` is re-invoked from scratch each attempt, so it +/// must rebuild any by-value request — and the caller must be content with a +/// possible duplicate on the rare "VTA processed it but the reply was lost" +/// timeout. That's a non-issue for reads (`get_key_secret`, `list_*`) and cheap +/// for `create_key` (at worst an orphan key); the join flow already rolls back a +/// half-minted persona. `label` names the op for the retry log line. +pub async fn retry(label: &str, mut op: F) -> Result +where + F: FnMut() -> Fut, + Fut: std::future::Future>, +{ + let mut attempt = 1; + loop { + match op().await { + Ok(v) => return Ok(v), + Err(e) if attempt < MAX_ATTEMPTS && retryable(&e) => { + let backoff = RETRY_BASE * (1 << (attempt - 1)); + tracing::warn!( + "VTA '{label}' failed (attempt {attempt}/{MAX_ATTEMPTS}): {e} — \ + retrying in {backoff:?}" + ); + tokio::time::sleep(backoff).await; + attempt += 1; + } + Err(e) => return Err(e), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn retryable_covers_transport_and_5xx_only() { + // Transient transport / server faults — retry may clear them. + assert!(retryable(&VtaError::DidcommTransport("timeout".into()))); + assert!(retryable(&VtaError::Server { + status: 503, + body: String::new(), + })); + // Deterministic faults — re-sending can't change the outcome. + assert!(!retryable(&VtaError::Validation("bad".into()))); + assert!(!retryable(&VtaError::Conflict("dup".into()))); + assert!(!retryable(&VtaError::NotFound("gone".into()))); + assert!(!retryable(&VtaError::Auth("expired".into()))); + } + + #[tokio::test(start_paused = true)] + async fn returns_immediately_on_success() { + let calls = Cell::new(0u32); + let out: Result = retry("ok", || { + calls.set(calls.get() + 1); + async { Ok(7) } + }) + .await; + assert_eq!(out.unwrap(), 7); + assert_eq!(calls.get(), 1, "no retry when the first attempt succeeds"); + } + + #[tokio::test(start_paused = true)] + async fn retries_transient_then_succeeds() { + // Models a stale socket: first send times out, ATM reconnects, second + // send lands. The op should be retried and ultimately succeed. + let calls = Cell::new(0u32); + let out: Result = retry("stale-then-ok", || { + let n = calls.get() + 1; + calls.set(n); + async move { + if n < 2 { + Err(VtaError::DidcommTransport("stale socket".into())) + } else { + Ok(9) + } + } + }) + .await; + assert_eq!(out.unwrap(), 9); + assert_eq!(calls.get(), 2); + } + + #[tokio::test(start_paused = true)] + async fn gives_up_after_max_attempts() { + let calls = Cell::new(0u32); + let out: Result = retry("always-down", || { + calls.set(calls.get() + 1); + async { Err(VtaError::DidcommTransport("down".into())) } + }) + .await; + assert!(out.is_err()); + assert_eq!(calls.get(), MAX_ATTEMPTS as u32); + } + + #[tokio::test(start_paused = true)] + async fn does_not_retry_deterministic_error() { + let calls = Cell::new(0u32); + let out: Result = retry("validation", || { + calls.set(calls.get() + 1); + async { Err(VtaError::Validation("nope".into())) } + }) + .await; + assert!(out.is_err()); + assert_eq!(calls.get(), 1, "deterministic faults are not retried"); + } +} diff --git a/openvtc-core/tests/common/mod.rs b/openvtc-core/tests/common/mod.rs index 5cae85bf..270f3627 100644 --- a/openvtc-core/tests/common/mod.rs +++ b/openvtc-core/tests/common/mod.rs @@ -237,6 +237,39 @@ pub async fn start_profile_messaging( profile: TestProfile, routes: &[&'static str], inbound_tx: mpsc::UnboundedSender, +) -> Result { + let wanted: Vec = routes.iter().map(|r| (*r).to_string()).collect(); + start_profile(profile, move |received: Received| { + if wanted.contains(&received.message.typ) { + let _ = inbound_tx.send(received.message); + } + }) + .await +} + +/// A message as the transport delivered it: unpacked, with the sender the +/// transport *verified* (authcrypt), or `None` when it could not. +pub struct Received { + pub message: Message, + pub authenticated: Option, +} + +/// Like [`start_profile_messaging`], but every inbound message — whatever its +/// type — goes to `inbound_tx` with its authenticated sender, so a test can put +/// it through the inbound gate and the dispatcher exactly as the app does. +pub async fn start_profile_inbound( + profile: TestProfile, + inbound_tx: mpsc::UnboundedSender, +) -> Result { + start_profile(profile, move |received| { + let _ = inbound_tx.send(received); + }) + .await +} + +async fn start_profile( + profile: TestProfile, + sink: impl Fn(Received) + Send + 'static, ) -> Result { let TestProfile { alias, @@ -276,7 +309,6 @@ pub async fn start_profile_messaging( Duration::from_millis(200), )); - let wanted: Vec = routes.iter().map(|r| (*r).to_string()).collect(); let dispatcher = { let mut inbound = service.subscribe(); tokio::spawn(async move { @@ -287,9 +319,18 @@ pub async fn start_profile_messaging( let Ok(message) = serde_json::from_slice::(&item.message.payload) else { continue; }; - if wanted.contains(&message.typ) { - let _ = inbound_tx.send(message); - } + // The delivery layer reports the hop it unwrapped (the + // mediator's forward) as `sender`, not the authcrypt sender of + // the inner message, and keeps the unpack metadata to itself. + // Every message in these tests is authcrypt-packed by the + // harness (`ProfileMessaging::send`), so the inner `from` is + // the sealing party; it stands in for what the app reads from + // `encrypted_from_kid`. A plaintext message gets none. + let authenticated = message.from.clone().filter(|_| item.message.encrypted); + sink(Received { + message, + authenticated, + }); } }) }; diff --git a/openvtc-core/tests/didcomm_transport_e2e.rs b/openvtc-core/tests/didcomm_transport_e2e.rs index 29f8c01f..fb411eed 100644 --- a/openvtc-core/tests/didcomm_transport_e2e.rs +++ b/openvtc-core/tests/didcomm_transport_e2e.rs @@ -39,8 +39,10 @@ use openvtc_core::didcomm::{ use tokio::sync::mpsc; use uuid::Uuid; -/// An OpenVTC protocol type the production catch-all pattern must route. -const OPENVTC_TYPE: &str = "https://linuxfoundation.org/openvtc/test/1.0/ping"; +/// An OpenVTC protocol type the inbound gate routes. A real one: the gate +/// admits exactly the types a handler claims (the inbound registry), so a +/// made-up type under the OpenVTC namespace is dropped before it is delivered. +const OPENVTC_TYPE: &str = openvtc_core::protocol_urls::RELATIONSHIP_REQUEST_REJECT; /// Stand up one profile's production `Messaging` runtime and install its listener. async fn start_production_service( diff --git a/openvtc-core/tests/inbound_dispatch_e2e.rs b/openvtc-core/tests/inbound_dispatch_e2e.rs new file mode 100644 index 00000000..c37e4e8a --- /dev/null +++ b/openvtc-core/tests/inbound_dispatch_e2e.rs @@ -0,0 +1,476 @@ +//! Inbound dispatch, end to end: a peer sends over a real in-process mediator, +//! this client's transport unpacks and authenticates the message, and it goes +//! through the inbound gate and `process_inbound_message` exactly as the +//! runtime loop puts it — including a deferred proof check run off the loop and +//! handed back. +//! +//! Alice plays the other party (a peer, or a community); Bob is this client's +//! persona, with a `Config` built for each scenario. One test per family: +//! +//! * a trust-pong closing our ping, and a trust-ping recorded as a task; +//! * a relationship request, with its DID-binding proof checked against +//! Alice's resolved `did:peer`, queued in the inbox; +//! * a VRC issued in an established relationship, its proof checked, queued; +//! * a community's signed refusal of our pending join, which rejects it; +//! * a type no handler claims, refused by the gate and ignored by dispatch. +//! +//! `#[ignore]`d like the rest of the mediator suite (the boot costs ~1s); CI's +//! coverage job runs them with `--include-ignored`, and locally: +//! +//! cargo test -p openvtc-core --test inbound_dispatch_e2e -- --ignored + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use affinidi_tdk::TDK; +use affinidi_tdk::didcomm::Message; +use affinidi_tdk::messaging::profiles::{ATMProfile, ATMProfileInner}; +use affinidi_tdk::secrets_resolver::secrets::Secret; +use openvtc_core::config::account::{CommunityRecord, CommunityStatus, PersonaId, PersonaRecord}; +use openvtc_core::config::{ + Config, KeyBackend, account::Account, protected_config::ProtectedConfig, + public_config::PublicConfig, secured_config::ProtectionMethod, +}; +use openvtc_core::didcomm::{Messaging, routes_inbound_type}; +use openvtc_core::inbound::{Arrival, InboundEffects, process_inbound_message}; +use openvtc_core::messaging::SeenMessages; +use openvtc_core::protocol_urls; +use openvtc_core::relationships::{ + BindingRole, Relationship, RelationshipRequestBody, RelationshipState, did_binding_proofs, +}; +use openvtc_core::tasks::TaskType; +use tokio::sync::mpsc; + +use common::{MockMediator, ProfileMessaging, Received, init_test_tracing, start_profile_inbound}; + +/// Alice (sending) and Bob (receiving, every type), connected. +struct Pair { + alice: ProfileMessaging, + _bob: ProfileMessaging, + alice_did: String, + bob_did: String, + /// Alice's Ed25519 key: signs her proofs and documents. + alice_key: Secret, + bob_rx: mpsc::UnboundedReceiver, + _mediator: MockMediator, +} + +async fn connect() -> Pair { + init_test_tracing(); + let mediator = MockMediator::start().await.expect("mediator start"); + let alice = mediator.profile("alice").expect("alice"); + let bob = mediator.profile("bob").expect("bob"); + let (alice_did, bob_did) = (alice.did.clone(), bob.did.clone()); + let alice_key = alice + .secrets + .iter() + .find(|s| { + s.get_public_keymultibase() + .is_ok_and(|mb| mb.starts_with("z6Mk")) + }) + .cloned() + .expect("alice has an Ed25519 key"); + + let (bob_tx, bob_rx) = mpsc::unbounded_channel(); + let bob = start_profile_inbound(bob, bob_tx).await.expect("bob up"); + let (alice_tx, _alice_rx) = mpsc::unbounded_channel(); + let alice = start_profile_inbound(alice, alice_tx) + .await + .expect("alice up"); + bob.wait_connected(Duration::from_secs(15)) + .await + .expect("bob connected"); + alice + .wait_connected(Duration::from_secs(15)) + .await + .expect("alice connected"); + Pair { + alice, + _bob: bob, + alice_did, + bob_did, + alice_key, + bob_rx, + _mediator: mediator, + } +} + +impl Pair { + /// Alice sends `message` to Bob; Bob's transport hands it over. + async fn deliver(&mut self, message: &Message) -> Received { + self.alice + .send(message, &self.bob_did) + .await + .expect("alice sends"); + // Bob's mailbox also carries the mediator's own traffic (pickup + // status); the gate drops that, and it is not what this test sent. + loop { + let received = tokio::time::timeout(Duration::from_secs(15), self.bob_rx.recv()) + .await + .expect("bob receives within 15s") + .expect("inbound channel open"); + if received.message.id == message.id { + return received; + } + assert!( + !routes_inbound_type(&received.message.typ), + "only mediator housekeeping arrives besides what Alice sent: {}", + received.message.typ + ); + } + } + + fn message(&self, typ: &str, body: serde_json::Value) -> Message { + Message::build(uuid::Uuid::new_v4().to_string(), typ.to_string(), body) + .from(self.alice_did.clone()) + .to(self.bob_did.clone()) + .created_time(chrono::Utc::now().timestamp() as u64) + .finalize() + } +} + +async fn offline_tdk() -> TDK { + TDK::new( + affinidi_tdk::common::config::TDKConfig::builder() + .with_load_environment(false) + .build() + .expect("TDK config"), + None, + ) + .await + .expect("TDK builds offline") +} + +/// Bob's account: one persona, `bob_did`, with its runtime identity. +fn bob_config(bob_did: &str) -> (Config, PersonaId) { + let mut config = Config { + protected_key: None, + integrity: Default::default(), + public: PublicConfig::default(), + private: ProtectedConfig::default(), + key_backend: KeyBackend::Bip32 { + root: ed25519_dalek_bip32::ExtendedSigningKey::from_seed(&[7u8; 32]).unwrap(), + seed: secrecy::SecretString::new("seed".into()), + }, + key_info: std::collections::HashMap::new(), + protection_method: ProtectionMethod::default(), + #[cfg(feature = "openpgp-card")] + token_admin_pin: None, + #[cfg(feature = "openpgp-card")] + token_user_pin: secrecy::SecretString::new("".into()), + unlock_code: None, + account: Account::default(), + identities: std::collections::BTreeMap::new(), + active_persona: None, + runtime_trust_overrides: None, + }; + let pid = PersonaId::new(); + config.account.personas.insert( + pid, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id: pid, + did: bob_did.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: "openvtc".into(), + created_at: chrono::Utc::now(), + label: None, + }, + ); + config.identities.insert( + pid, + openvtc_core::identity::IdentityContext { + persona_id: pid, + did: bob_did.to_string(), + document: serde_json::from_value(serde_json::json!({ "id": bob_did })) + .expect("minimal DID document"), + profile: Arc::new(ATMProfile { + inner: Arc::new(ATMProfileInner { + did: bob_did.to_string(), + alias: bob_did.to_string(), + mediator: Arc::new(None), + }), + }), + mediator_did: None, + }, + ); + (config, pid) +} + +/// An established relationship between Bob and Alice, both on their personas. +fn with_relationship(config: &mut Config, alice: &str, bob: &str) { + let alice = Arc::new(alice.to_string()); + config.private.relationships.relationships.insert( + Arc::clone(&alice), + Relationship { + task_id: Arc::new(uuid::Uuid::new_v4().to_string()), + our_did: Arc::new(bob.to_string()), + remote_did: Arc::clone(&alice), + remote_p_did: alice, + created: chrono::Utc::now(), + state: RelationshipState::Established, + our_persona: None, + needs_reestablishment: false, + }, + ); +} + +/// What the runtime loop does with one arrival: the gate, then dispatch; a +/// message set aside for a proof check has the check run and is dispatched +/// again with its result. Returns whether the config changed and the effects of +/// the last pass. Panics if the gate refuses it — use [`gate_refuses`] for that. +async fn dispatch(config: &mut Config, tdk: &TDK, received: &Received) -> (bool, InboundEffects) { + assert!( + routes_inbound_type(&received.message.typ), + "the gate admits {}", + received.message.typ + ); + let (tx, _rx) = mpsc::unbounded_channel(); + let service: Messaging = + openvtc_core::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let nobody = |_: &str| false; + let mut arrival = Arrival::New { waiting: &nobody }; + let mut message = received.message.clone(); + let mut changed = false; + for _ in 0..3 { + let mut effects = InboundEffects::default(); + changed |= process_inbound_message( + config, + tdk, + &service, + &mut seen, + &message, + received.authenticated.as_deref(), + &mut effects, + arrival, + ) + .await + .expect("dispatch"); + let Some(deferred) = effects.deferred.take() else { + return (changed, effects); + }; + arrival = Arrival::Returning(deferred.job.run(tdk.clone()).await); + message = deferred.message; + } + panic!("a returning message is handled, not set aside again"); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "slow: spawns the in-process mediator (~1s)"] +async fn a_trust_pong_closes_our_ping_and_a_ping_is_recorded() { + let mut pair = connect().await; + let tdk = offline_tdk().await; + let (mut config, _) = bob_config(&pair.bob_did); + with_relationship(&mut config, &pair.alice_did, &pair.bob_did); + let alice = Arc::new(pair.alice_did.clone()); + + // Our ping to Alice, waiting on its pong. + let ping_id = Arc::new(uuid::Uuid::new_v4().to_string()); + config.private.tasks.new_task( + &ping_id, + TaskType::TrustPing { + from: Arc::new(pair.bob_did.clone()), + to: Arc::clone(&alice), + remote_p_did: Arc::clone(&alice), + }, + ); + let mut pong = pair.message(protocol_urls::TRUST_PONG, serde_json::json!({})); + pong.thid = Some(ping_id.to_string()); + let received = pair.deliver(&pong).await; + let (changed, _) = dispatch(&mut config, &tdk, &received).await; + assert!(changed); + assert!( + config.private.tasks.get_by_id(&ping_id).is_none(), + "the pong closed our ping" + ); + + // Alice pings us: recorded for the member to see. + let ping = pair.message( + protocol_urls::TRUST_PING, + serde_json::json!({ "response_requested": true }), + ); + let ping_task = Arc::new(ping.id.clone()); + let received = pair.deliver(&ping).await; + let (changed, _) = dispatch(&mut config, &tdk, &received).await; + assert!(changed); + assert!(matches!( + config.private.tasks.get_by_id(&ping_task).map(|t| &t.type_), + Some(TaskType::TrustPing { .. }) + )); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "slow: spawns the in-process mediator (~1s)"] +async fn a_relationship_request_is_proven_then_queued() { + let mut pair = connect().await; + let tdk = offline_tdk().await; + let (mut config, _) = bob_config(&pair.bob_did); + + let id = uuid::Uuid::new_v4().to_string(); + let (did_proof, persona_proof) = did_binding_proofs( + &pair.alice_did, + &pair.alice_did, + &pair.bob_did, + &id, + BindingRole::Request, + &pair.alice_key, + &pair.alice_key, + ) + .await + .expect("Alice proves her DID for this request"); + let body = RelationshipRequestBody { + reason: Some("e2e".into()), + did: pair.alice_did.clone(), + name: Some("Alice".into()), + did_proof: Some(did_proof), + persona_proof, + }; + let mut request = pair.message( + protocol_urls::RELATIONSHIP_REQUEST, + serde_json::to_value(&body).unwrap(), + ); + request.id = id.clone(); + let received = pair.deliver(&request).await; + let (changed, _) = dispatch(&mut config, &tdk, &received).await; + assert!(changed, "the request was queued"); + match config + .private + .tasks + .get_by_id(&Arc::new(id)) + .map(|t| &t.type_) + { + Some(TaskType::RelationshipRequestInbound { from, .. }) => { + assert_eq!(from.as_str(), pair.alice_did) + } + other => panic!("expected an inbound relationship request, got {other:?}"), + } +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "slow: spawns the in-process mediator (~1s)"] +async fn a_vrc_in_an_established_relationship_is_proven_then_queued() { + let mut pair = connect().await; + let tdk = offline_tdk().await; + let (mut config, _) = bob_config(&pair.bob_did); + with_relationship(&mut config, &pair.alice_did, &pair.bob_did); + + let mut vrc = dtg_credentials::DTGCredential::new_vrc( + pair.alice_did.clone(), + dtg_credentials::IssuerScope::Pairwise, + pair.bob_did.clone(), + chrono::Utc::now(), + None, + ); + vrc.sign(&pair.alice_key, None).await.expect("Alice signs"); + let issued = pair.message( + protocol_urls::VRC_ISSUED, + serde_json::to_value(&vrc).unwrap(), + ); + let task = Arc::new(issued.id.clone()); + let received = pair.deliver(&issued).await; + let (changed, _) = dispatch(&mut config, &tdk, &received).await; + assert!(changed, "the VRC was queued"); + assert!(matches!( + config.private.tasks.get_by_id(&task).map(|t| &t.type_), + Some(TaskType::VRCIssued { .. }) + )); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "slow: spawns the in-process mediator (~1s)"] +async fn a_communitys_signed_refusal_rejects_our_pending_join() { + let mut pair = connect().await; + let tdk = offline_tdk().await; + let (mut config, pid) = bob_config(&pair.bob_did); + // Alice is the community; our join to it is pending. + let request_id = uuid::Uuid::new_v4(); + config.account.add_membership(CommunityRecord::new_pending( + pair.alice_did.clone(), + None, + "openvtc/alice".into(), + pid, + request_id, + chrono::Utc::now(), + )); + + let mut document = serde_json::from_value(serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": "https://trusttasks.org/spec/trust-task-error/0.1", + "issuer": pair.alice_did, + "recipient": pair.bob_did, + "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "threadId": request_id.to_string(), + "payload": { + "code": "permissionDenied", + "message": "not admitted", + "retryable": false, + }, + })) + .expect("a refusal document"); + openvtc_core::capabilities::sign_document(&mut document, &pair.alice_key) + .await + .expect("the community signs its refusal"); + let mut refusal = pair.message( + openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE, + serde_json::to_value(&document).unwrap(), + ); + refusal.thid = Some(request_id.to_string()); + let received = pair.deliver(&refusal).await; + let (changed, effects) = dispatch(&mut config, &tdk, &received).await; + assert!(changed); + assert_eq!( + config + .account + .membership(&pair.alice_did, pid) + .map(|m| m.status.clone()), + Some(CommunityStatus::Rejected), + "the signed refusal rejected the join" + ); + assert_eq!(effects.inactivated.len(), 1, "its session is torn down"); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "slow: spawns the in-process mediator (~1s)"] +async fn a_type_no_handler_claims_is_refused_by_the_gate() { + let mut pair = connect().await; + let tdk = offline_tdk().await; + let (mut config, _) = bob_config(&pair.bob_did); + with_relationship(&mut config, &pair.alice_did, &pair.bob_did); + + let unknown = pair.message( + "https://example.com/not-a-verb-we-speak/1.0", + serde_json::json!({}), + ); + let received = pair.deliver(&unknown).await; + assert!( + !routes_inbound_type(&received.message.typ), + "the gate drops a type no handler claims" + ); + // And were it put to the dispatcher anyway, nothing changes. + let (tx, _rx) = mpsc::unbounded_channel(); + let service = + openvtc_core::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut effects = InboundEffects::default(); + let nobody = |_: &str| false; + let changed = process_inbound_message( + &mut config, + &tdk, + &service, + &mut SeenMessages::new(), + &received.message, + received.authenticated.as_deref(), + &mut effects, + Arrival::New { waiting: &nobody }, + ) + .await + .expect("dispatch"); + assert!(!changed); + assert!(effects.deferred.is_none()); + assert!(config.private.tasks.tasks.is_empty()); +} diff --git a/openvtc-core/tests/inbound_gate_census.rs b/openvtc-core/tests/inbound_gate_census.rs new file mode 100644 index 00000000..2f078d27 --- /dev/null +++ b/openvtc-core/tests/inbound_gate_census.rs @@ -0,0 +1,288 @@ +//! The inbound gate and the dispatcher agree, because both are the registry. +//! +//! ## The failure this closes +//! +//! `openvtc_core::didcomm` drops any inbound message whose `type` the gate +//! refuses, with no reply. Not a refusal — a *drop*: the sender sees nothing but +//! a wait that never ends. A handler for a type the gate refuses is therefore a +//! verb that fails totally and silently, and that is not hypothetical: +//! `capabilities::send_capability_document` sent the binding envelope, the +//! dispatcher had a branch for exactly that type, and every reply was dropped +//! between them. A TSP-joined member's `git-ns` answers went the same way. +//! +//! The gate and the dispatcher used to be two descriptions of one fact — a +//! regex and a chain of branches — and this file was a hand-written list +//! reconciling them. Both are now derived from +//! [`openvtc_core::inbound::registry::ROUTES`] (issue #531), so the checks here +//! are about the registry itself: every family reachable, the routes disjoint, +//! the gate exactly the registry, and the types this client is known to +//! receive all routed. + +use openvtc_core::didcomm::routes_inbound_type; +use openvtc_core::inbound::registry::{Family, ROUTES, classify}; + +/// Types this client is known to receive, from the constants that define +/// them where a constant exists — so a change of version upstream is a change +/// here too. Each with the family that must handle it. +fn routed_types() -> Vec<(&'static str, Family)> { + use openvtc_core::protocol_urls as p; + vec![ + (p::RELATIONSHIP_REQUEST, Family::RelationshipRequest), + ( + p::RELATIONSHIP_REQUEST_REJECT, + Family::RelationshipRequestRejected, + ), + ( + p::RELATIONSHIP_REQUEST_ACCEPT, + Family::RelationshipRequestAccepted, + ), + ( + p::RELATIONSHIP_REQUEST_FINALIZE, + Family::RelationshipRequestFinalize, + ), + (p::TRUST_PING, Family::TrustPing), + (p::TRUST_PONG, Family::TrustPong), + (p::VRC_REQUEST, Family::VrcRequest), + (p::VRC_REJECTED, Family::VrcRequestRejected), + (p::VRC_ISSUED, Family::VrcIssued), + ( + openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE, + Family::BindingEnvelope, + ), + ( + vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RECEIPT_TYPE, + Family::JoinSubmitReceipt, + ), + ( + openvtc_core::vetting::protocol::JoinProtocol::V0_4.submit_response_type(), + Family::JoinVerdict, + ), + ( + vta_sdk::protocols::join_requests::JOIN_REQUEST_STATUS_RESPONSE_TYPE, + Family::JoinStatusResponse, + ), + ( + vta_sdk::protocols::credential_exchange::ISSUE, + Family::CredentialIssue, + ), + ( + vta_sdk::protocols::PROBLEM_REPORT_TYPE, + Family::ProblemReport, + ), + ( + openvtc_core::personhood::PERSONHOOD_CHALLENGE_RESPONSE_TYPE, + Family::PersonhoodChallengeResponse, + ), + ( + openvtc_core::personhood::PERSONHOOD_ASSERT_RESPONSE_TYPE, + Family::PersonhoodAssertResponse, + ), + ( + openvtc_core::renewal::MEMBERS_RENEW_RESPONSE_TYPE, + Family::RenewalResponse, + ), + ( + vta_sdk::protocols::members::MEMBER_VMC_RESPONSE_TYPE, + Family::MemberVmcResponse, + ), + ( + vta_sdk::protocols::members::MEMBER_REQUEST_VMC_TYPE, + Family::MemberRequestVmc, + ), + ( + vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE, + Family::MemberRemovalNotice, + ), + ( + openvtc_core::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE, + Family::CommunityProfileResponse, + ), + ( + "https://trusttasks.org/spec/governance/capability/list/0.1#response", + Family::CapabilityReply, + ), + ( + "https://trusttasks.org/spec/governance/capability/enable/0.1#response", + Family::CapabilityReply, + ), + // TSP opens the binding envelope before the gate, so these arrive typed + // as the task; missing from the gate, every Repos answer to a + // TSP-joined member was dropped and the view timed out. + ( + "https://trusttasks.org/spec/git-ns/view/0.6#response", + Family::GitNsReply, + ), + ( + "https://trusttasks.org/spec/git-ns/repo/create/0.4#response", + Family::GitNsReply, + ), + ( + "https://trusttasks.org/spec/git-ns/right/grant/0.4#response", + Family::GitNsReply, + ), + ( + "https://trusttasks.org/spec/vtc/rooms/memberships/list/0.2#response", + Family::RoomsReply, + ), + ( + "https://trusttasks.org/spec/vtc/rooms/offers/accept/0.1#response", + Family::RoomsReply, + ), + ( + "https://trusttasks.org/spec/vtc/elections/list/0.5#response", + Family::ElectionsReply, + ), + ( + "https://trusttasks.org/spec/vtc/elections/tickets/issue/0.3#response", + Family::ElectionsReply, + ), + ( + openvtc_core::notifications::NOTIFY_TYPE, + Family::Notification, + ), + ( + openvtc_core::notifications::RELAY_TYPE, + Family::Notification, + ), + ( + "https://trusttasks.org/spec/witness/session/0.2#response", + Family::WitnessReply, + ), + ( + "https://trusttasks.org/spec/witness/session/submit/0.2#response", + Family::WitnessReply, + ), + // Peer vetting travels member to member under its own prefix. + ( + "https://trusttasks.org/spec/vetting/request/0.1", + Family::Vetting, + ), + ( + "https://trusttasks.org/spec/vetting/session/0.1#response", + Family::Vetting, + ), + ( + "https://trusttasks.org/spec/vetting/decline/0.1", + Family::Vetting, + ), + ( + vta_sdk::protocols::vetting::VETTING_VETTER_LIST_RESPONSE_TYPE, + Family::Vetting, + ), + ( + openvtc_core::vetting::wire::VETTER_RESEND_RESPONSE_TYPE, + Family::Vetting, + ), + ( + "https://trusttasks.org/spec/trust-task-error/0.5", + Family::TrustTaskError, + ), + ] +} + +/// **The census.** Every type this client receives is admitted by the gate and +/// routed to the family that handles it. +#[test] +fn every_routed_type_is_admitted_and_reaches_its_family() { + let mut wrong = Vec::new(); + for (uri, family) in routed_types() { + if !routes_inbound_type(uri) { + wrong.push(format!("{uri}: dropped by the gate")); + } + if classify(uri) != Some(family) { + wrong.push(format!( + "{uri}: routed to {:?}, expected {family:?}", + classify(uri) + )); + } + } + assert!(wrong.is_empty(), "{}", wrong.join("\n")); +} + +/// Every family has a route, so none is a handler the gate never reaches. +#[test] +fn every_family_is_reachable() { + for family in Family::ALL { + assert!( + ROUTES.iter().any(|r| r.family == *family), + "{family:?} has a handler but no route" + ); + } +} + +/// Routes are disjoint: no type is claimed by two of them, so their order in +/// the table means nothing. (Families that share traffic — a credential +/// delivery vetting reads first — do so inside their handler, not by +/// overlapping routes.) +#[test] +fn no_type_is_claimed_by_two_routes() { + for (uri, _) in routed_types() { + let claims: Vec<_> = ROUTES + .iter() + .filter(|r| r.matcher.matches(uri)) + .map(|r| r.family) + .collect(); + assert!(claims.len() <= 1, "{uri} is claimed by {claims:?}"); + } +} + +/// The gate is the registry: it admits exactly what [`classify`] routes. +#[test] +fn the_gate_is_the_registry() { + for (uri, _) in routed_types() { + assert_eq!(routes_inbound_type(uri), classify(uri).is_some(), "{uri}"); + } + for uri in not_routed() { + assert_eq!(routes_inbound_type(uri), classify(uri).is_some(), "{uri}"); + } +} + +/// The trust-task error family is matched by *prefix*: every version has to +/// pass, not just the one that happens to be current. +#[test] +fn every_trust_task_error_version_is_admitted() { + for uri in [ + "https://trusttasks.org/spec/trust-task-error/0.1", + "https://trusttasks.org/spec/trust-task-error/0.2", + "https://trusttasks.org/spec/trust-task-error/0.5", + "https://trusttasks.org/spec/trust-task-error/9.9", + ] { + assert_eq!( + classify(uri), + Some(Family::TrustTaskError), + "{uri} — a peer on a different framework version would have its \ + refusals silently dropped" + ); + } +} + +/// Types this client does not handle. The old regex admitted some of these +/// (whole namespaces: `spec/vtc/*`, `openvtc/vtc/*`, any `linuxfoundation.org/ +/// openvtc/*`) and the dispatcher then dropped them as unknown; the gate now +/// drops them first, with the same outcome. +fn not_routed() -> Vec<&'static str> { + vec![ + "https://trusttasks.org/spec/acl/list/0.1", + "https://trusttasks.org/spec/policy/upsert/0.2", + "https://example.com/whatever", + "https://didcomm.org/messagepickup/3.0/status", + // Requests we send, never receive. + "https://trusttasks.org/spec/vtc/members/self-remove/0.1", + // The pre-migration VTC authority: no handler speaks it. + "https://trusttasks.org/openvtc/vtc/spec/join-requests/submit/1.0", + "https://linuxfoundation.org/openvtc/1.0/something-new", + // A TSP binding envelope over DIDComm: only TSP opens it, before the gate. + "https://trusttasks.org/binding/tsp/0.1/envelope", + ] +} + +/// The gate is a routing decision, not a catch-everything. +#[test] +fn the_gate_refuses_what_no_handler_claims() { + for uri in not_routed() { + assert!( + !routes_inbound_type(uri), + "{uri} must not be routed — no handler claims it" + ); + } +} diff --git a/openvtc/Cargo.toml b/openvtc/Cargo.toml index 6a6b34b2..1effb447 100644 --- a/openvtc/Cargo.toml +++ b/openvtc/Cargo.toml @@ -33,7 +33,6 @@ openvtc-core.workspace = true ratatui.workspace = true affinidi-tdk.workspace = true -affinidi-data-integrity.workspace = true anyhow.workspace = true # did-git-sign's library reads and writes the signer's profiles, config and # commit-msg hook, so the Repos view and `openvtc repo sign` can set up and @@ -93,6 +92,9 @@ libc.workspace = true # `test-util`: the paused clock (`start_paused`) for the save-debounce tests. # `net` / `io-util`: the loopback listener in the `health_cmd` tests. tokio = { workspace = true, features = ["io-util", "net", "test-util"] } +# `test-support`: the inbound `VerifyJob`s that hang or panic, for the +# verify-queue tests. Dev-only, so a release build never carries them. +openvtc-core = { workspace = true, features = ["test-support"] } [[bin]] name = "openvtc" diff --git a/openvtc/src/cli.rs b/openvtc/src/cli.rs index d7bb848f..8f6da2d3 100644 --- a/openvtc/src/cli.rs +++ b/openvtc/src/cli.rs @@ -214,6 +214,16 @@ mod tests { }, OpenVTCError::Vta("x".into()), OpenVTCError::Auth("x".into()), + OpenVTCError::remote( + openvtc_core::errors::RemoteService::Vta, + openvtc_core::errors::RemoteKind::AuthRejected, + "x", + ), + OpenVTCError::remote( + openvtc_core::errors::RemoteService::Vta, + openvtc_core::errors::RemoteKind::Timeout, + "x", + ), OpenVTCError::Resolver("x".into()), OpenVTCError::Config("x".into()), ]; diff --git a/openvtc/src/main.rs b/openvtc/src/main.rs index 2a6897b8..8f80f979 100644 --- a/openvtc/src/main.rs +++ b/openvtc/src/main.rs @@ -691,14 +691,26 @@ async fn main() -> Result<()> { } starting_mode = StartingMode::SetupWizard; } - Err(e @ (OpenVTCError::Vta(_) | OpenVTCError::Auth(_))) => { + Err( + e @ (OpenVTCError::Vta(_) + | OpenVTCError::Auth(_) + | OpenVTCError::Remote { + service: openvtc_core::errors::RemoteService::Vta, + .. + }), + ) => { // R18: a VTA connection/auth failure is a retryable runtime fault, - // NOT config corruption. Surface "check VTA / re-auth" guidance and - // reserve reset-style messaging for genuine `Config` errors below. - let hint = if matches!(e, OpenVTCError::Auth(_)) { - "Check that your credentials are still valid and re-authenticate, then try again." - } else { - "Check that your VTA is reachable and try again." + // NOT config corruption. Surface guidance for the failure's own + // class (R6.4) and reserve reset-style messaging for genuine + // `Config` errors below. + let hint = match e.class() { + openvtc_core::errors::ErrorClass::AuthRejected => { + "Check that your credentials are still valid and re-authenticate, then try again." + } + openvtc_core::errors::ErrorClass::Unreachable => { + "Check that your VTA is reachable and try again." + } + other => other.remedy(), }; eprintln!( "{} {}", diff --git a/openvtc/src/repos_cmd.rs b/openvtc/src/repos_cmd.rs index aa472ed2..c441e022 100644 --- a/openvtc/src/repos_cmd.rs +++ b/openvtc/src/repos_cmd.rs @@ -26,8 +26,8 @@ use tokio::sync::mpsc; use crate::colors::{CLI_ERROR, CLI_INFO, Themed}; use crate::headless::{self, Admin, Membership, progress, save, warn}; -use crate::state_handler::join_cmd::apply_inbound; use crate::state_handler::{DeferredLoad, repos_actions}; +use openvtc_core::inbound::apply_inline as apply_inbound; /// How long a request waits for the community's answer (as the panel does). const REPLY_WINDOW: Duration = Duration::from_secs(30); diff --git a/openvtc/src/state_handler/community_actions.rs b/openvtc/src/state_handler/community_actions.rs index 2f16368b..e1411a24 100644 --- a/openvtc/src/state_handler/community_actions.rs +++ b/openvtc/src/state_handler/community_actions.rs @@ -54,7 +54,7 @@ pub(crate) enum Verb { }, /// `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. + /// [`openvtc_core::inbound`]; nothing here waits for it. RequestPersonhoodChallenge { document_signer: Box }, /// `members/personhood/assert` — present the evidence over that nonce. /// @@ -73,7 +73,7 @@ pub(crate) enum Verb { /// `members/renew` — ask the community to re-issue this membership's VMC /// and role VAC. The reply carries both and arrives asynchronously; it is /// verified, stored and acknowledged by - /// [`crate::state_handler::message_dispatch`]. + /// [`openvtc_core::inbound`]. /// /// `document_signer` is the persona's authentication key: the task /// declares `proof` REQUIRED. diff --git a/openvtc/src/state_handler/community_context_actions.rs b/openvtc/src/state_handler/community_context_actions.rs index 3fc598e6..1aa512a3 100644 --- a/openvtc/src/state_handler/community_context_actions.rs +++ b/openvtc/src/state_handler/community_context_actions.rs @@ -65,6 +65,7 @@ fn panel(state: &mut State) -> &mut CommunitiesState { fn reason(e: &OpenVTCError) -> String { match e { OpenVTCError::Config(m) | OpenVTCError::Vta(m) | OpenVTCError::Auth(m) => m.clone(), + OpenVTCError::Remote { detail, .. } => detail.clone(), other => other.to_string(), } } diff --git a/openvtc/src/state_handler/community_profile_poll.rs b/openvtc/src/state_handler/community_profile_poll.rs index 8883e08d..204a9d02 100644 --- a/openvtc/src/state_handler/community_profile_poll.rs +++ b/openvtc/src/state_handler/community_profile_poll.rs @@ -11,7 +11,7 @@ //! //! ## Pacing: once per session, not backed off //! -//! Unlike [`super::join_status_poll`], there is nothing to reconcile toward — the +//! Unlike [`openvtc_core::join::status_poll`], there is nothing to reconcile toward — the //! value is near-static community metadata, not a lifecycle that resolves. So a //! community is asked **once per process session** and then left alone: the reply //! caches on every membership of that community diff --git a/openvtc/src/state_handler/create_persona.rs b/openvtc/src/state_handler/create_persona.rs index 31fee34b..c33b1695 100644 --- a/openvtc/src/state_handler/create_persona.rs +++ b/openvtc/src/state_handler/create_persona.rs @@ -28,7 +28,6 @@ use openvtc_core::config::{ }; use openvtc_core::errors::OpenVTCError; -use crate::state_handler::join_flow; use crate::state_handler::setup_sequence::{ SetupState, config::{ConfigExtension, NO_VTA_MEDIATOR, vta_mediator}, @@ -56,7 +55,7 @@ const FALLBACK_SLUG: &str = "persona"; pub(crate) fn auto_context(config: &Config, label: &str) -> Result { let top = &config.account.top_context_id; let suggested = community_context::suggested_context_id(top, label, FALLBACK_SLUG, |id| { - join_flow::context_taken(config, id) + openvtc_core::join::sequence::context_taken(config, id) }) .map_err(|e| match e { OpenVTCError::Config(message) => message, diff --git a/openvtc/src/state_handler/elections_actions.rs b/openvtc/src/state_handler/elections_actions.rs index a92dd5c6..b221f161 100644 --- a/openvtc/src/state_handler/elections_actions.rs +++ b/openvtc/src/state_handler/elections_actions.rs @@ -57,15 +57,9 @@ const CAST: DispatchDomain = DispatchDomain::ElectionsCast; /// How long a request waits for the community's answer (R1.2). pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); -/// A `vtc/elections/*` answer as it arrived. -pub(crate) struct InboundReply { - /// The transport-authenticated sender. - pub(crate) from: String, - /// The document's `issuer`. - pub(crate) issuer: Option, - pub(crate) thid: String, - pub(crate) reply: Reply, -} +/// A `vtc/elections/*` answer as it arrived, threaded on the request it answers +/// ([`openvtc_core::inbound::ThreadedReply`]). +pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; fn did_part(did: &str) -> &str { did.split('#').next().unwrap_or(did) diff --git a/openvtc/src/state_handler/inbound_glue_tests.rs b/openvtc/src/state_handler/inbound_glue_tests.rs new file mode 100644 index 00000000..b7e2a317 --- /dev/null +++ b/openvtc/src/state_handler/inbound_glue_tests.rs @@ -0,0 +1,177 @@ +//! Inbound dispatch (core) feeding the TUI's views: a reply taken by +//! [`openvtc_core::inbound::process_inbound_message`] and applied to a view by +//! the CLI's glue. The dispatcher's own tests live with it in +//! `openvtc_core::inbound`. + +use std::sync::Arc; + +use affinidi_tdk::secrets_resolver::secrets::Secret; +use affinidi_tdk::{TDK, didcomm::Message}; +use openvtc_core::config::Config; +use openvtc_core::config::account::{CommunityRecord, PersonaId, PersonaRecord}; +use openvtc_core::inbound::{Arrival, InboundEffects, process_inbound_message}; +use openvtc_core::messaging::SeenMessages; + +use crate::state_handler::dispatch_util::{test_config, test_tdk}; + +const PERSONA: &str = "did:webvh:QmP:example.com:alice"; + +fn community_key() -> (String, Secret) { + let mut s = Secret::generate_ed25519(None, Some(&[0x71; 32])); + let mb = s.get_public_keymultibase().unwrap(); + let did = format!("did:key:{mb}"); + s.id = format!("{did}#{mb}"); + (did, s) +} + +/// One persona, with a runtime identity, holding an Active membership of `vtc`. +fn member_config(vtc: &str) -> Config { + use affinidi_tdk::messaging::profiles::{ATMProfile, ATMProfileInner}; + let mut config = test_config(); + let pid = PersonaId::new(); + config.account.personas.insert( + pid, + PersonaRecord { + extra: serde_json::Map::new(), + persona_id: pid, + did: PERSONA.into(), + did_document: None, + did_locator: None, + key_refs: vec![], + mediator_did: None, + origin_context_id: "openvtc".into(), + created_at: chrono::Utc::now(), + label: None, + }, + ); + let mut record = CommunityRecord::new_pending( + vtc.into(), + None, + "openvtc/x".into(), + pid, + uuid::Uuid::new_v4(), + chrono::Utc::now(), + ); + record.activate(chrono::Utc::now()); + config.account.add_membership(record); + config.identities.insert( + pid, + openvtc_core::identity::IdentityContext { + persona_id: pid, + did: PERSONA.to_string(), + document: serde_json::from_value(serde_json::json!({ "id": PERSONA })) + .expect("minimal DID document"), + profile: Arc::new(ATMProfile { + inner: Arc::new(ATMProfileInner { + did: PERSONA.to_string(), + alias: PERSONA.to_string(), + mediator: Arc::new(None), + }), + }), + mediator_did: None, + }, + ); + config +} + +async fn dispatch( + config: &mut Config, + tdk: &TDK, + m: &Message, + arrival: Arrival<'_>, +) -> InboundEffects { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let service = + openvtc_core::didcomm::start_empty_service(tx, tokio_util::sync::CancellationToken::new()); + let mut seen = SeenMessages::new(); + let mut effects = InboundEffects::default(); + process_inbound_message( + config, + tdk, + &service, + &mut seen, + m, + m.from.as_deref(), + &mut effects, + arrival, + ) + .await + .unwrap(); + effects +} + +/// Dispatch `m` once it has been through its off-loop check, as the loop does. +async fn dispatch_checked(config: &mut Config, tdk: &TDK, m: &Message) -> InboundEffects { + let effects = dispatch(config, tdk, m, Arrival::FRESH).await; + let deferred = effects.deferred.expect("set aside for its check"); + let pre = deferred.job.run(tdk.clone()).await; + dispatch(config, tdk, &deferred.message, Arrival::Returning(pre)).await +} + +/// A TSP-joined member's Repos view loads from the community's +/// `git-ns/view/0.6` answer, taken the whole way a live one is: sealed in +/// the TSP binding envelope, mapped and passed through the inbound gate +/// (`deliver_tsp_document`), checked off the loop and dispatched +/// (`process_inbound_message`), then correlated by the Repos view +/// (`repos_actions::apply_replies`). The gate used to drop this exact type +/// at debug, and the view timed out on an answer the VTC had sent. +#[tokio::test(flavor = "current_thread")] +async fn a_tsp_git_ns_view_answer_loads_the_repos_view() { + use crate::state_handler::main_page::repos::{Pending, Purpose, ReposPhase, ReposView}; + let (vtc, key) = community_key(); + let tdk = test_tdk().await; + let mut config = member_config(&vtc); + let thid = format!("urn:uuid:{}", uuid::Uuid::new_v4()); + + // The signed document, as the VTC sends it over TSP. + let mut document = serde_json::from_value(serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": "https://trusttasks.org/spec/git-ns/view/0.6#response", + "issuer": vtc, + "recipient": PERSONA, + "issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true), + "threadId": thid, + "payload": { + "accounts": [], + "namespaces": [{"id": "ns_1", "forge": "github.com", "owner": "acme", + "kind": "organization", "mode": "bridge", "state": "bound"}], + "repos": [{"resource": "github.com/acme/gadgets", "forgeId": "1", + "visibility": "public", "state": "active", "owners": [PERSONA], + "bootstrap": {"workflow": true, "keyring": true, + "variables": true, "requiredCheck": true}, + "sync": {"state": "inSync", "drift": []}}], + "rights": [{"subject": PERSONA, "right": "git.repo.own", + "resource": "github.com/acme/gadgets", "grantedBy": PERSONA, + "grantedAt": "2026-09-02T00:00:00Z"}] + }, + })) + .expect("a reply document"); + openvtc_core::capabilities::sign_document(&mut document, &key) + .await + .expect("sign the reply"); + let wrapped = vta_sdk::tsp_binding::wrap_envelope( + &serde_json::to_vec(&document).expect("document bytes"), + ); + let m = openvtc_core::didcomm::deliver_tsp_document(&wrapped, &vtc, PERSONA) + .expect("the answer passes the inbound gate"); + + let effects = dispatch_checked(&mut config, &tdk, &m).await; + assert_eq!(effects.git_ns_replies.len(), 1, "handed to the Repos view"); + + // The Repos view, waiting on the request this answers. + let pid = config.account.persona_id_for_did(PERSONA).unwrap(); + let mut state = crate::state_handler::state::State::default(); + let mut view = ReposView::new(vtc.clone(), pid, PERSONA.into(), "Acme".into()); + view.pending = Some(Pending { + thid: thid.clone(), + sent_at: std::time::Instant::now(), + purpose: Purpose::View, + }); + state.main_page.content_panel.repos.view = Some(view); + + crate::state_handler::repos_actions::apply_replies(&mut state, &config, effects.git_ns_replies); + let view = state.main_page.content_panel.repos.view.as_ref().unwrap(); + assert_eq!(view.phase, ReposPhase::Loaded); + assert!(view.pending.is_none(), "the wait is over"); + assert_eq!(view.my_repos().len(), 1); +} diff --git a/openvtc/src/state_handler/inbox_actions.rs b/openvtc/src/state_handler/inbox_actions.rs index d08cd222..d79c0064 100644 --- a/openvtc/src/state_handler/inbox_actions.rs +++ b/openvtc/src/state_handler/inbox_actions.rs @@ -325,7 +325,7 @@ pub(crate) struct AcceptJob { tdk: TDK, service: Messaging, /// `Some` ⇒ mint an R-DID first; `None` ⇒ use the persona DID directly. - rdid_plan: Option, + rdid_plan: Option, persona_did: Arc, /// The persona's authentication key, which signs the DID binding. persona_auth: Secret, @@ -372,7 +372,7 @@ impl AcceptJob { // 2. Build + send the acceptance via the persona listener (handshake uses // persona DIDs for routing; the R-DID is carried in the body). let built = async { - let did_signer = crate::state_handler::relationship_actions::relationship_did_signer( + let did_signer = openvtc_core::relationships::keys::relationship_did_signer( &tdk, &our_did, &persona_did, @@ -671,11 +671,9 @@ async fn prepare_accept_relationship( // creation runs in the task). let rdid_plan = if generate_r_did { let mediator = config.mediator_did().to_string(); - Some( - crate::state_handler::relationship_actions::plan_relationship_did( - config, &mediator, admin_vta, - )?, - ) + Some(openvtc_core::relationships::keys::plan_relationship_did( + config, &mediator, admin_vta, + )?) } else { None }; diff --git a/openvtc/src/state_handler/join_cmd.rs b/openvtc/src/state_handler/join_cmd.rs index 6bd379f9..6a5e7ad4 100644 --- a/openvtc/src/state_handler/join_cmd.rs +++ b/openvtc/src/state_handler/join_cmd.rs @@ -2,8 +2,8 @@ //! //! Covers the two ways in that need no questions answered: presenting an //! invitation, or sending an open request. The submit mirrors -//! `join_flow::run_join_sequence` for an existing persona; the wait reuses the -//! TUI's inbound dispatch and join status poll, so a verdict is applied exactly +//! `join_flow::run_join_sequence` for an existing persona; the wait reuses +//! core's inbound dispatch and join status poll, so a verdict is applied exactly //! as the runtime loop would apply it. use std::time::Duration; @@ -15,7 +15,7 @@ use chrono::Utc; use clap::{Arg, ArgMatches, Command}; use openvtc_core::config::{ Config, PeerTransports, PersonaDIDKeys, - account::{CommunityRecord, CommunityStatus, PersonaId}, + account::{CommunityStatus, PersonaId}, community_context, }; use openvtc_core::didcomm::{DIDCommEvent, Messaging, MessagingTransport}; @@ -32,8 +32,7 @@ use vta_sdk::protocols::did_management::create::WebvhPathMode; use crate::headless::{self, Admin, progress, save, step}; use super::main_page::{sanitize_display, shorten_did}; -use super::message_dispatch::{Arrival, InboundEffects, process_inbound_message}; -use super::{DeferredLoad, create_persona, join_flow, join_status_poll}; +use super::{DeferredLoad, create_persona}; /// Bound on resolving the community's DID (same as the TUI's). /// How long the submit waits for the persona's mediator socket. @@ -226,7 +225,7 @@ impl Job<'_> { if let Some((keys, prior_friendly_name)) = minted && config.account.membership(vtc_did, persona_id).is_none() { - join_flow::forget_minted_persona( + openvtc_core::join::sequence::forget_minted_persona( config, persona_id, keys.as_ref(), @@ -250,7 +249,7 @@ impl Job<'_> { /// Read the manifest over HTTP and refuse what this command cannot answer. async fn learn_requirements(&self, config: &mut Config) -> Result<()> { let vtc_did = self.vtc_did; - match join_flow::learn_over_http(config, self.tdk, vtc_did).await { + match openvtc_core::join::sequence::learn_over_http(config, self.tdk, vtc_did).await { Ok(()) => {} Err(e) if !e.resolvable() => bail!("{vtc_did}: {}", e.reason()), Err(e) => progress(format!( @@ -437,18 +436,18 @@ impl Job<'_> { .build_presentation(config, persona_id, persona_did, vic.as_ref()) .await?; let (request_id, submit_transport) = self - .send(config, persona_id, persona_did, transports, presentation) + .send(config, persona_id, transports, presentation) .await?; - let mut record = CommunityRecord::new_pending( + let mut record = openvtc_core::join::sequence::pending_record( vtc_did.to_string(), display_name, context_id, persona_id, request_id, Utc::now(), + submit_transport, ); - record.submit_transport = Some(submit_transport); record.directory_statement_digest = directory_statement_digest; config.account.add_membership(record); config.private.logs.insert( @@ -471,47 +470,30 @@ impl Job<'_> { &self, config: &Config, persona_id: PersonaId, - persona_did: &str, transports: &PeerTransports, presentation: openvtc_core::join::JoinPresentation, ) -> Result<(uuid::Uuid, MessagingTransport)> { + use openvtc_core::join::sequence::{self, SubmitError}; let vtc_did = self.vtc_did; - let (profile, mediator) = match config.identities.get(&persona_id) { - Some(ident) => ( - ident.profile().clone(), - ident.mediator_did.clone().unwrap_or_default(), - ), + let mediator = match config.identities.get(&persona_id) { + Some(ident) => ident.mediator_did.clone().unwrap_or_default(), None => bail!("this persona has no messaging identity"), }; - let tsp_mediator = - tsp_mediator(vtc_did, &mediator, transports.didcomm_mediator.is_some()).await; - let submit_transport = if tsp_mediator.is_some() { - MessagingTransport::Tsp - } else { - MessagingTransport::DidComm - }; - - let signer = config - .get_persona_keys_for(persona_id, self.tdk) - .await - .context("couldn't read the persona's signing key")? - .authentication - .secret - .clone(); - let route = openvtc_core::join::Applicant { - atm: self.tdk.atm.as_ref().expect("checked at load"), - profile: &profile, - persona_did, - signer: &signer, - vtc_did, - mediator_did: &mediator, - tsp_mediator_did: tsp_mediator.as_deref(), - }; - let request_id = openvtc_core::join::submit_join_request(&route, presentation) - .await - .context("failed to submit the join request")?; - - Ok((request_id, submit_transport)) + let route = sequence::choose_submit_route(vtc_did, &mediator, transports).await; + if let Some(note) = route.note { + progress(note.describe()); + } + let request_id = + sequence::submit(config, self.tdk, persona_id, vtc_did, &route, presentation) + .await + .map_err(|e| match e { + SubmitError::Signer(e) => { + anyhow!(e).context("couldn't read the persona's signing key") + } + SubmitError::Send(e) => anyhow!(e).context("failed to submit the join request"), + other => anyhow!(other), + })?; + Ok((request_id, route.transport())) } /// The membership's context: the persona's own, or a new one named after @@ -524,7 +506,7 @@ impl Job<'_> { ) -> Result { let vtc_did = self.vtc_did; let top = config.account.top_context_id.clone(); - let suggested = join_flow::suggested_context(config, vtc_did); + let suggested = openvtc_core::join::sequence::suggested_context(config, vtc_did); let context_id = community_context::context_options( &config.account, config.account.personas.get(&persona_id), @@ -642,7 +624,7 @@ impl Job<'_> { let vtc_did = self.vtc_did; let deadline = tokio::time::Instant::now() + wait; let mut seen = SeenMessages::new(); - let mut pacer = join_status_poll::PollPacer::default(); + let mut pacer = openvtc_core::join::status_poll::PollPacer::default(); let mut poll = tokio::time::interval_at(tokio::time::Instant::now() + POLL_FIRST, POLL_EVERY); let tsp_dirty = self.messaging.tsp_store().dirty(); @@ -711,8 +693,8 @@ impl Job<'_> { if !due.is_empty() && let Some(atm) = self.tdk.atm.clone() { - let polls = join_status_poll::build(config, self.tdk, due).await; - join_status_poll::send_all(atm, polls).await; + let polls = openvtc_core::join::status_poll::build(config, self.tdk, due).await; + openvtc_core::join::status_poll::send_all(atm, polls).await; } } _ = tsp_dirty.notified() => { @@ -745,7 +727,7 @@ impl Job<'_> { message: &Message, authenticated: Option<&str>, ) -> bool { - apply_inbound( + openvtc_core::inbound::apply_inline( config, self.tdk, self.messaging, @@ -757,52 +739,6 @@ impl Job<'_> { } } -/// The runtime loop's inbound handling: dispatch, run any deferred check -/// inline, dispatch again with its result. `true` when the config changed. -/// Shared by every headless command that listens as a persona, so a message -/// it was not waiting for is applied rather than lost. -/// -/// `authenticated` is the sender the transport bound to the frame; the -/// message is handled as that sender only (see `process_inbound_message`). -pub(crate) async fn apply_inbound( - config: &mut Config, - tdk: &TDK, - messaging: &Messaging, - seen: &mut SeenMessages, - message: &Message, - authenticated: Option<&str>, -) -> bool { - let nobody = |_: &str| false; - let mut arrival = Arrival::New { waiting: &nobody }; - let mut message = message.clone(); - let mut changed = false; - // Bounded: a returning message is handled, not set aside again. - for _ in 0..3 { - let mut effects = InboundEffects::default(); - match process_inbound_message( - config, - tdk, - messaging, - seen, - &message, - authenticated, - &mut effects, - arrival, - ) - .await - { - Ok(c) => changed |= c, - Err(e) => warn!(error = %e, "inbound message not handled"), - } - let Some(deferred) = effects.deferred else { - break; - }; - arrival = Arrival::Returning(deferred.job.run(tdk.clone()).await); - message = deferred.message; - } - changed -} - /// The persona a join goes out as. enum Pick { Existing(PersonaId), @@ -819,16 +755,6 @@ fn persona_did(config: &Config, persona_id: PersonaId) -> Result { .ok_or_else(|| anyhow!("the chosen persona is not in the account")) } -/// TSP only when both ends carry it, as the TUI decides. -async fn tsp_mediator(vtc_did: &str, mediator: &str, didcomm_fallback: bool) -> Option { - let peer = openvtc_core::config::peer_tsp_mediator(vtc_did).await?; - match openvtc_core::config::our_mediator_carries_tsp(mediator).await { - Some(true) => Some(peer), - Some(false) | None if didcomm_fallback => None, - _ => Some(peer), - } -} - /// Whether the community has acknowledged the pending request. fn receipt_seen(config: &Config, vtc_did: &str, persona_id: PersonaId) -> bool { config diff --git a/openvtc/src/state_handler/join_flow.rs b/openvtc/src/state_handler/join_flow.rs index 3dd4e37e..e6634b62 100644 --- a/openvtc/src/state_handler/join_flow.rs +++ b/openvtc/src/state_handler/join_flow.rs @@ -21,15 +21,14 @@ use openvtc_core::config::{ Config, account::{CommunityRecord, PersonaId, VtcDid}, community_context::{self, ContextKind, ContextOption}, - context_path::{build_sub_context_id, parse_sub_context_id}, + context_path::parse_sub_context_id, }; use openvtc_core::didcomm::Messaging; -use openvtc_core::health::ProbePolicy; +use openvtc_core::join::sequence; use openvtc_core::logs::LogFamily; use openvtc_core::vetting::applicant::Application; use openvtc_core::vetting::book::Knowledge; use openvtc_core::vetting::disclosure::{Describe, DirectoryStatement}; -use openvtc_core::vetting::discover; use openvtc_core::vetting::guide::describe_requirements; use openvtc_core::vetting::queries::{CommunityAnswer, CommunityQuery, QueryKind}; use openvtc_core::vetting::wire; @@ -198,10 +197,6 @@ pub(crate) const CHALLENGE_WAIT: Duration = REQUIREMENTS_WAIT; /// How long sending the question may take before the page says so. const ASK_TIMEOUT: Duration = Duration::from_secs(15); -/// How long resolving a community's DID may take before its endpoint is given -/// up on. Bounded by rule R1.2, like every other outbound step here. -const RESOLVE_TIMEOUT: Duration = Duration::from_secs(10); - /// The entry a community's answer makes for the join waiting on it, if the /// answer is about that community's requirements. pub(crate) fn resume_for( @@ -709,7 +704,11 @@ fn application_contexts( persona: Option, ) -> Vec { let record = persona.and_then(|p| config.account.personas.get(&p)); - community_context::context_options(&config.account, record, &suggested_context(config, vtc_did)) + community_context::context_options( + &config.account, + record, + &sequence::suggested_context(config, vtc_did), + ) } /// The invitation row's detail: how many are held, and when the longest-lived @@ -1191,146 +1190,6 @@ fn show_unknown( state.join.page = JoinPage::Vetting; } -/// Why a community could not be asked what it requires. -/// -/// The distinction is not cosmetic: it decides whether "join anyway" is an -/// offer at all. A community that resolves but will not answer is one you can -/// still send a request to, and its moderators will see it. A DID that does not -/// resolve names nothing — there is no document, no endpoint and no mediator -/// route, so a join request addressed to it goes nowhere and waits for an -/// answer nobody can send. Offering the same key for both told someone who had -/// mistyped a DID that joining was "the way forward here" (rule R6.4). -pub(super) enum NotLearned { - /// The identifier did not resolve to a DID document. - Unresolvable(String), - /// It resolved; the manifest could not be read from it. - Unanswered(String), -} - -impl NotLearned { - /// The sentence the page shows. - pub(super) fn reason(&self) -> &str { - match self { - NotLearned::Unresolvable(why) | NotLearned::Unanswered(why) => why, - } - } - - /// Whether there is something at this DID to send a join request to. - pub(super) fn resolvable(&self) -> bool { - matches!(self, NotLearned::Unanswered(_)) - } -} - -/// Read `vtc_did`'s manifest from the REST endpoint it publishes, and learn it. -/// -/// The question a community answers about what it requires is a public read -/// (`vetting-process.md` §6.1), and serving it over HTTPS is what makes it -/// answerable on a first join: no mediator socket, no persona to ask as, and no -/// loop that can hear a reply. `Err` is the sentence the page shows, already -/// distinguishing the ways it can fail — rule R6.4. -/// -/// Mirrors the DIDComm handler -/// ([`openvtc_core::vetting::inbound`]): the book learns the manifest, and any -/// application to this community adopts it, so a change to the requirements -/// mid-application is detectable from either route rather than only from one. -pub(super) async fn learn_over_http( - config: &mut Config, - tdk: &TDK, - vtc_did: &str, -) -> Result<(), NotLearned> { - let resolver = tdk.did_resolver(); - let resolved = tokio::time::timeout(RESOLVE_TIMEOUT, resolver.resolve(vtc_did)) - .await - .map_err(|_| { - // A timeout is not "there is nothing there": the resolver may - // simply be slow or the host briefly down, and the DID is still - // worth sending to. - NotLearned::Unanswered(format!( - "it could not be resolved within {} seconds", - RESOLVE_TIMEOUT.as_secs() - )) - })? - .map_err(|e| NotLearned::Unresolvable(format!("it could not be resolved ({e})")))?; - let doc = serde_json::to_value(&resolved.doc) - .map_err(|e| NotLearned::Unanswered(format!("its DID document could not be read ({e})")))?; - let discover::FetchedManifest { - manifest, - raw, - protocol, - meta, - } = discover::fetch_manifest(&doc, vtc_did, resolver, ProbePolicy::PublicOnly) - .await - .map_err(|e| NotLearned::Unanswered(e.to_string()))?; - - let now = Utc::now(); - let before = config.private.vetting.vetter_mode(vtc_did).mode(); - config - .private - .vetting - .learn_manifest_in(vtc_did, &manifest, Some(protocol), &meta, now); - // What its directory would show about the person joining, shown on the - // review page before anything is sent (`manifest/0.4`). - config.private.vetting.learn_directory(vtc_did, &raw); - // Whether it vets by PCS ZKP now, read from the same answer — so the badges, the vetter - // desk and this join agree on one reading, whichever route brought it. - config - .private - .vetting - .learn_mode(vtc_did, &raw, before, now); - // The document is in hand already, so how the community signs is free to - // learn — and before joining it is the only evidence there is. - note_signing(config, vtc_did, &resolved.doc); - let book = &mut config.private.vetting; - for application in book - .applications - .iter_mut() - .filter(|a| a.community == vtc_did) - { - if let Err(e) = application.adopt_manifest(&manifest, &raw) { - // Not debug-only: an application that cannot take up its criterion gathers - // evidence for the wrong one. The vetting page re-reads it (m) and says why. - warn!(community = %vtc_did, error = %e, "community manifest not adopted"); - } - } - Ok(()) -} - -/// Record how `vtc_did` signs, from its resolved DID document: whether it lists -/// a post-quantum signing key. The book keeps it only for a community it -/// already knows, so this follows learning the manifest. -fn note_signing(config: &mut Config, vtc_did: &str, doc: &affinidi_tdk::did_common::Document) { - config.private.vetting.note_post_quantum_key( - vtc_did, - openvtc_core::proof_check::publishes_post_quantum_key(doc), - ); -} - -/// Learn how `vtc_did` signs when the book does not know yet, by resolving its -/// DID document — bounded by [`RESOLVE_TIMEOUT`] (rule R1.2). -/// -/// Reading the manifest over HTTP resolves the document anyway and records -/// this on the way; a manifest that arrived over DIDComm, or one already in the -/// book from an earlier visit, never did, so the vetting page said "Signing: -/// not checked" about a community whose document resolves perfectly well. -/// Best-effort: a resolve that fails or runs out of time leaves it unknown, -/// and the page says it could not be checked rather than guessing either way. -async fn learn_signing(config: &mut Config, tdk: &TDK, vtc_did: &str) { - if config.private.vetting.post_quantum_key(vtc_did).is_some() { - return; - } - match tokio::time::timeout(RESOLVE_TIMEOUT, tdk.did_resolver().resolve(vtc_did)).await { - Ok(Ok(resolved)) => note_signing(config, vtc_did, &resolved.doc), - Ok(Err(e)) => { - debug!(community = %vtc_did, error = %e, "community DID not resolved; signing not checked"); - } - Err(_) => debug!( - community = %vtc_did, - timeout_secs = RESOLVE_TIMEOUT.as_secs(), - "community DID resolve timed out; signing not checked" - ), - } -} - /// Ask `vtc_did` for its manifest, as the active persona, and show the page /// that waits for the answer. The answer reaches the caller's loop, not this /// one. `Err` means the page now says why the question could not be asked. @@ -1619,7 +1478,7 @@ impl StateHandler { if learned { // The manifest came over DIDComm, which never // resolved the community's DID document. - learn_signing(config, tdk, &vtc_did).await; + sequence::learn_signing(config, tdk, &vtc_did).await; } let shown = learned && show_vetting(state, config, &vtc_did); if !shown @@ -1808,7 +1667,7 @@ impl StateHandler { ) { // Say what the wait is for: a name costs a // redirect, a resolve and a check (bounded by - // IDENTIFIER_RESOLVE_TIMEOUT). + // IDENTIFIER_sequence::RESOLVE_TIMEOUT). state.join.messages.push(MessageType::Info(format!( "Resolving {vtc_did}…" ))); @@ -2159,7 +2018,7 @@ impl StateHandler { match community_context::new_context_id( &config.account.top_context_id, &state.join.context_slug, - |id| context_taken(config, id), + |id| sequence::context_taken(config, id), ) { Ok(id) => id, Err(e) => { @@ -2451,7 +2310,7 @@ impl StateHandler { // Only when the community cannot be reached does the stored copy stand // in, and the log says the page is showing an older answer. if knowledge.is_some() { - match learn_over_http(config, tdk, &vtc_did).await { + match sequence::learn_over_http(config, tdk, &vtc_did).await { Ok(()) => knowledge = known(config), Err(why) => { debug!(community = %vtc_did, reason = %why.reason(), "manifest not re-read"); @@ -2468,7 +2327,7 @@ impl StateHandler { Some(true) => { // The re-read above records how it signs; if it could not be // made, the DID document may still resolve on its own. - learn_signing(config, tdk, &vtc_did).await; + sequence::learn_signing(config, tdk, &vtc_did).await; show_vetting(state, config, &vtc_did); None } @@ -2477,7 +2336,7 @@ impl StateHandler { // persona and no loop that can hear a reply, so it answers on a // first join — where the DIDComm question cannot be asked at // all — and it answers in one round trip everywhere else. - match learn_over_http(config, tdk, &vtc_did).await { + match sequence::learn_over_http(config, tdk, &vtc_did).await { Ok(()) => { if show_vetting(state, config, &vtc_did) { return None; @@ -3076,26 +2935,6 @@ fn open_invitation_choice(state: &mut State, config: &Config, persona_id: Person state.join.page = JoinPage::InvitationChoice; } -/// Whether `context_id` is already claimed: by a membership, a persona's keys, -/// or a vetting application's face. -pub(crate) fn context_taken(config: &Config, context_id: &str) -> bool { - community_context::context_claimed( - &config.account, - &config.private.vetting.applications, - context_id, - ) -} - -/// The new sub-context to suggest for `vtc_did`: named after the community, -/// and not yet claimed. -pub(crate) fn suggested_context(config: &Config, vtc_did: &str) -> String { - let top = &config.account.top_context_id; - let taken = |id: &str| context_taken(config, id); - build_sub_context_id(top, config.agent_name_for(vtc_did), vtc_did, taken) - .or_else(|_| build_sub_context_id(top, Some("community"), vtc_did, taken)) - .unwrap_or_else(|_| top.clone()) -} - /// Offer the contexts `pick` can join `vtc_did` in. /// /// Returns the context when there is nothing to ask — a persona whose keys @@ -3122,7 +2961,7 @@ fn offer_contexts( } IdentityPick::Mint => None, }; - let suggested = suggested_context(config, vtc_did); + let suggested = sequence::suggested_context(config, vtc_did); let options = community_context::context_options(&config.account, persona, &suggested); if options.len() == 1 { return options.into_iter().next().map(|o| o.context_id); @@ -3631,33 +3470,6 @@ fn is_duplicate_membership(config: &Config, vtc_did: &str, persona: PersonaId) - config.account.has_live_membership(vtc_did, persona) } -/// Build the `Pending` [`CommunityRecord`] recorded on a successful submit. -/// -/// Pure decision peeled out of step 9 of [`run_join_sequence`]; delegates to -/// [`CommunityRecord::new_pending`] so the record shape stays defined in core. -fn build_pending_record( - vtc_did: String, - display_name: Option, - sub_context_id: String, - persona_id: PersonaId, - request_id: uuid::Uuid, - now: chrono::DateTime, - submit_transport: openvtc_core::didcomm::MessagingTransport, -) -> CommunityRecord { - let mut record = CommunityRecord::new_pending( - vtc_did, - display_name, - sub_context_id, - persona_id, - request_id, - now, - ); - // Which transport carried the submit, so an unacknowledged join can name it - // rather than leaving every cause looking alike. - record.submit_transport = Some(submit_transport); - record -} - /// Run the automated mint → sub-context → join-submit → persist sequence. /// /// All progress and errors land in `state.join`. On success @@ -3976,7 +3788,7 @@ async fn run_join_sequence( state.join.info("Submitting join request…"); let _ = handler.state_tx.send(state.clone()); - let Some(atm) = tdk.atm.as_ref() else { + if tdk.atm.is_none() { state .join .fail("Messaging (ATM) unavailable — cannot submit the join request."); @@ -3984,14 +3796,10 @@ async fn run_join_sequence( rollback_minted_persona(config, persona_id, state, profile, prior_friendly_name); } return; - }; - let (applicant_did, persona_profile, persona_mediator) = match config - .identities - .get(&persona_id) - { + } + let (applicant_did, persona_mediator) = match config.identities.get(&persona_id) { Some(ident) => ( ident.persona_did().to_string(), - ident.profile().clone(), ident.mediator_did.clone().unwrap_or_default(), ), None => { @@ -4281,84 +4089,38 @@ async fn run_join_sequence( ..vp.into() }, }; - // Settle the wire. TSP needs **both** legs, which is the workspace rule that - // the protocol is the highest-preference one present in *both* parties' - // documents — and here it is not a formality. A TSP send posts the raw CESR - // frame to *our* mediator's `/inbound`; the community's advertised mediator - // is only the hop the routing layer is sealed to and never sees the request. - // A mediator without its `tsp` feature therefore rejects the frame as an - // unparseable DIDComm envelope, and does so while the error names the *other* - // mediator — an hour of debugging for a question answerable up front. - // - // `None` at either end sends over DIDComm exactly as before, so a community - // that has not been flipped is unaffected. - let vtc_tsp_mediator = match openvtc_core::config::peer_tsp_mediator(&vtc_did).await { - None => None, - Some(peer_mediator) => { - match openvtc_core::config::our_mediator_carries_tsp(&persona_mediator).await { - Some(true) => Some(peer_mediator), - // Our mediator does not carry TSP. Prefer DIDComm where the - // community offers it — a working join beats a correct-looking - // one. With TSP the community's *only* transport there is - // nothing to fall back to, so send it and let the mediator - // answer; refusing here would only replace one failure with - // another, minus the evidence. - Some(false) if transports.didcomm_mediator.is_some() => { - state.join.info( - "Your mediator does not carry TSP — submitting over DIDComm instead.", - ); - let _ = handler.state_tx.send(state.clone()); - None - } - Some(false) => { - state.join.info( - "This community offers TSP only, and your mediator does not \ - advertise it — attempting the submit over TSP anyway.", - ); - let _ = handler.state_tx.send(state.clone()); - Some(peer_mediator) - } - // Could not ask. Unknown is not "no": take the community's - // preference where it has an alternative, since DIDComm is the - // transport that worked before TSP existed. - None if transports.didcomm_mediator.is_some() => None, - None => Some(peer_mediator), - } - } - }; - let submit_transport = if vtc_tsp_mediator.is_some() { - openvtc_core::didcomm::MessagingTransport::Tsp - } else { - openvtc_core::didcomm::MessagingTransport::DidComm - }; + // Settle the wire (TSP needs both legs; `choose_submit_route` says why). + let route = + openvtc_core::join::sequence::choose_submit_route(&vtc_did, &persona_mediator, &transports) + .await; + if let Some(note) = route.note { + state.join.info(note.describe()); + let _ = handler.state_tx.send(state.clone()); + } + let submit_transport = route.transport(); // Last gate before the request goes out: the socket started above has had // the invitation + VP work to come up, so this is normally already true. let applicant_online = await_persona_online(handler, state, messaging, &applicant_did).await; - // The submit is signed: `join-requests/submit/0.2` declares `proof` - // REQUIRED, and a persona whose key cannot be read cannot apply — better - // said here than as a refusal from the community. - // The persona's authentication key: it signs the request document. - let signing_secret = match config.get_persona_keys_for(persona_id, tdk).await { - Ok(keys) => keys.authentication.secret.clone(), - Err(e) => { + let request_id = match openvtc_core::join::sequence::submit( + config, + tdk, + persona_id, + &vtc_did, + &route, + presentation, + ) + .await + { + Ok(id) => id, + // A persona whose key cannot be read cannot apply. Nothing was sent; + // the persona is kept, as it always was on this path. + Err(openvtc_core::join::sequence::SubmitError::Signer(e)) => { state .join .fail(format!("Couldn't sign the application: {e}")); return; } - }; - let route = openvtc_core::join::Applicant { - atm, - profile: &persona_profile, - persona_did: &applicant_did, - signer: &signing_secret, - vtc_did: &vtc_did, - mediator_did: &persona_mediator, - tsp_mediator_did: vtc_tsp_mediator.as_deref(), - }; - let request_id = match openvtc_core::join::submit_join_request(&route, presentation).await { - Ok(id) => id, Err(e) => { state .join @@ -4388,7 +4150,7 @@ async fn run_join_sequence( } // 9. Record the pending membership and persist. - let record = build_pending_record( + let record = sequence::pending_record( vtc_did.clone(), display_name, sub_context_id, @@ -5029,7 +4791,7 @@ fn rollback_minted_persona( profile: &str, prior_friendly_name: &str, ) { - forget_minted_persona( + sequence::forget_minted_persona( config, persona_id, state.setup.did_keys.as_ref(), @@ -5040,27 +4802,6 @@ fn rollback_minted_persona( } } -/// Remove a persona minted for a join that then failed, from the config only -/// (the caller saves). Shared with `community join`. -pub(crate) fn forget_minted_persona( - config: &mut Config, - persona_id: PersonaId, - keys: Option<&openvtc_core::config::PersonaDIDKeys>, - prior_friendly_name: &str, -) { - config.account.personas.remove(&persona_id); - config.identities.remove(&persona_id); - if let Some(keys) = keys { - config.key_info.remove(&keys.signing.secret.id); - config.key_info.remove(&keys.authentication.secret.id); - config.key_info.remove(&keys.decryption.secret.id); - } - // `mint_persona_into` set `public.friendly_name` to the attempted community's - // persona name; restore the pre-mint value so a failed/cancelled join doesn't - // leave the self-display name pointing at a community we never joined. - config.public.friendly_name = prior_friendly_name.to_string(); -} - fn save_config(config: &Config, profile: &str) -> Result<(), openvtc_core::errors::OpenVTCError> { config.save( profile, @@ -5236,8 +4977,8 @@ mod tests { assert!(!given.answered_for("did:web:another.example")); } use super::{ - build_pending_record, is_duplicate_membership, joined_session, load_pasted_vic, - parse_pasted_vic, start_persona_listener, stash_verified_vic, validate_join_input, + is_duplicate_membership, joined_session, load_pasted_vic, parse_pasted_vic, + start_persona_listener, stash_verified_vic, validate_join_input, }; use crate::Interrupted; use crate::state_handler::dispatch_util::test_config; @@ -5409,7 +5150,7 @@ mod tests { let persona = PersonaId::new(); let request_id = uuid::Uuid::new_v4(); let now = chrono::Utc::now(); - let rec = build_pending_record( + let rec = openvtc_core::join::sequence::pending_record( "did:vtc:c".to_string(), Some("Community".to_string()), "top/slug".to_string(), @@ -5445,7 +5186,7 @@ mod tests { // A successful sequence leaves the record + persona did → a session. let persona = PersonaId::new(); - let rec = build_pending_record( + let rec = openvtc_core::join::sequence::pending_record( "did:vtc:c".to_string(), None, "top/slug".to_string(), @@ -6940,7 +6681,7 @@ mod vetting_tests { .vetting .learn_manifest(VTC, &manifest(true), now); assert_eq!(config.private.vetting.post_quantum_key(VTC), None); - note_signing(&mut config, VTC, &document(with_pq)); + sequence::note_signing(&mut config, VTC, &document(with_pq)); assert_eq!(config.private.vetting.post_quantum_key(VTC), Some(with_pq)); let view = vetting_view(&config, VTC, &[], None, now).unwrap(); let shown = crate::ui::pages::join_flow::vetting_requirements::body_lines( diff --git a/openvtc/src/state_handler/mod.rs b/openvtc/src/state_handler/mod.rs index da2256af..25caab4a 100644 --- a/openvtc/src/state_handler/mod.rs +++ b/openvtc/src/state_handler/mod.rs @@ -292,13 +292,13 @@ mod device_presence; mod dispatch_util; mod hidden_vetting_poll; mod hidden_vetting_vta; +#[cfg(test)] +mod inbound_glue_tests; mod inbox_actions; pub mod join; pub(crate) mod join_cmd; mod join_flow; -mod join_status_poll; pub mod main_page; -mod message_dispatch; mod notification_actions; mod relationship_actions; mod runtime_actions; @@ -1306,7 +1306,7 @@ impl StateHandler { // pacer, so a minute-by-minute tick does not mean a minute-by-minute // poll; ticks over an account with no Pending join do no work at all. let mut join_status_tick = tokio::time::interval(std::time::Duration::from_secs(60)); - let mut join_status_pacer = join_status_poll::PollPacer::default(); + let mut join_status_pacer = openvtc_core::join::status_poll::PollPacer::default(); // Ask each Active community, once per session, for its declared // `relationshipIdentifierDefault` (issue #241). The pacer asks each @@ -1498,10 +1498,10 @@ impl StateHandler { // shows its membership as verifying. let returning_credential = matches!( pre, - Some(message_dispatch::PreVerified::Credential(_)) + Some(openvtc_core::inbound::PreVerified::Credential(_)) ); let returning = pre.is_some(); - let mut effects = message_dispatch::InboundEffects::default(); + let mut effects = openvtc_core::inbound::InboundEffects::default(); // Only the sender the transport authenticated can be // waited on: a claimed `from` alone neither holds a // community's messages back nor jumps its queue. @@ -1510,10 +1510,10 @@ impl StateHandler { && verifier.is_waiting_on(claimed) }; let arrival = match pre { - Some(pre) => message_dispatch::Arrival::Returning(pre), - None => message_dispatch::Arrival::New { waiting: &waiting }, + Some(pre) => openvtc_core::inbound::Arrival::Returning(pre), + None => openvtc_core::inbound::Arrival::New { waiting: &waiting }, }; - let dispatched = message_dispatch::process_inbound_message( + let dispatched = openvtc_core::inbound::process_inbound_message( &mut config, &tdk, &didcomm_service, @@ -1535,7 +1535,7 @@ impl StateHandler { save.mark_dirty(); } } - let message_dispatch::InboundEffects { + let openvtc_core::inbound::InboundEffects { inactivated, capability_replies, git_ns_replies, @@ -2512,7 +2512,7 @@ impl StateHandler { } // Ask each community about a join it has not resolved — // every Pending join, including one that never got any - // reply, which is asked id-less; see `join_status_poll`. + // reply, which is asked id-less; see `openvtc_core::join::status_poll`. // // Spawned detached rather than through `background_dispatch`: // there is no outcome to apply on the loop thread (the reply @@ -2524,9 +2524,9 @@ impl StateHandler { && let Some(atm) = tdk.atm.clone() { let due = join_status_pacer.due(candidates, std::time::Instant::now()); - let polls = join_status_poll::build(&config, &tdk, due).await; + let polls = openvtc_core::join::status_poll::build(&config, &tdk, due).await; if !polls.is_empty() { - tokio::spawn(join_status_poll::send_all(atm, polls)); + tokio::spawn(openvtc_core::join::status_poll::send_all(atm, polls)); } } }, @@ -5222,7 +5222,10 @@ fn build_trust_pong( async fn next_inbound( arriving: &mut mpsc::UnboundedReceiver, verifier: &mut verify_queue::Verifier, -) -> Option<(didcomm::DIDCommEvent, Option)> { +) -> Option<( + didcomm::DIDCommEvent, + Option, +)> { tokio::select! { biased; finished = verifier.finished() => Some(( @@ -5287,7 +5290,7 @@ fn queue_for_check( verifier: &mut verify_queue::Verifier, config: &mut openvtc_core::config::Config, state: &mut State, - deferred: message_dispatch::Deferred, + deferred: openvtc_core::inbound::Deferred, transport: didcomm::MessagingTransport, authenticated: Option<&str>, ) -> Result<(), verify_queue::Refused> { @@ -5369,8 +5372,8 @@ async fn restore_kept( backlog.push_front(kept); break; } - let mut effects = message_dispatch::InboundEffects::default(); - let _ = message_dispatch::process_inbound_message( + let mut effects = openvtc_core::inbound::InboundEffects::default(); + let _ = openvtc_core::inbound::process_inbound_message( config, tdk, service, @@ -5378,7 +5381,7 @@ async fn restore_kept( &kept.message, Some(&kept.sender), &mut effects, - message_dispatch::Arrival::Restored, + openvtc_core::inbound::Arrival::Restored, ) .await; let queued = match effects.deferred { @@ -5452,12 +5455,12 @@ mod tests { config } - fn waiting_message(id: &str, from: &str) -> message_dispatch::Deferred { - message_dispatch::Deferred { + fn waiting_message(id: &str, from: &str) -> openvtc_core::inbound::Deferred { + openvtc_core::inbound::Deferred { message: affinidi_tdk::didcomm::Message::build(id, "t", serde_json::json!({})) .from(from.to_string()) .finalize(), - job: message_dispatch::VerifyJob::Barrier, + job: openvtc_core::inbound::VerifyJob::Barrier, } } diff --git a/openvtc/src/state_handler/relationship_actions.rs b/openvtc/src/state_handler/relationship_actions.rs index 70af0800..dcf29d4f 100644 --- a/openvtc/src/state_handler/relationship_actions.rs +++ b/openvtc/src/state_handler/relationship_actions.rs @@ -3,24 +3,12 @@ use std::sync::Arc; use crate::state_handler::setup_sequence::vta; -use affinidi_tdk::{ - TDK, - affinidi_crypto::ed25519::ed25519_private_to_x25519, - didcomm::Message, - dids::{DID, PeerKeyRole}, - secrets_resolver::{SecretsResolver, secrets::Secret}, -}; +use affinidi_tdk::{TDK, didcomm::Message, secrets_resolver::secrets::Secret}; use anyhow::Result; -use base64::Engine; use chrono::Utc; -use ed25519_dalek_bip32::DerivationPath; use openvtc_core::didcomm::Messaging; use openvtc_core::{ - config::{ - Config, KeyBackend, KeyTypes, - account::RelationshipIdentifierDefault, - secured_config::{KeyInfoConfig, KeySourceMaterial}, - }, + config::{Config, account::RelationshipIdentifierDefault, secured_config::KeyInfoConfig}, logs::LogFamily, relationships::{RelationshipRequestBody, RelationshipState, did_binding_proofs}, tasks::TaskType, @@ -47,285 +35,6 @@ fn build_ping_message(from: &str, to: &str) -> Result { .finalize()) } -/// Loop-thread plan for creating a relationship DID, plus everything the -/// *I/O-only* [`RDidPlan::create_io`] needs to run off the loop without touching -/// `Config`. Building this is the only step that reads/mutates `Config` (the -/// BIP32 path-pointer reservation); the actual key creation (VTA round-trip or -/// BIP32 derivation), did:peer build, and resolver insert all run in the task. -pub(crate) enum RDidPlan { - /// BIP32: paths reserved on the loop thread (pointer already advanced); the - /// task derives the keys from a clone of the root. The root is boxed to keep - /// the enum compact (it is the largest field by far). - Bip32 { - root: Box, - v_path: String, - e_path: String, - mediator: String, - }, - /// VTA: the task creates the keys on the always-on admin session (cloned — - /// cheap, shares the connection pool). - Vta { - admin_vta: vta_sdk::client::VtaClient, - mediator: String, - }, -} - -/// The created relationship DID plus the `key_info` entries to record in -/// `Config` on the loop thread. The secrets themselves were already inserted into -/// the (shared, `Arc`-based) TDK resolver inside the task, so they are visible to -/// the loop's TDK; only the `Config` bookkeeping is left for `apply`. -pub(crate) struct CreatedRDid { - pub(crate) r_did: String, - pub(crate) key_info: Vec<(String, KeyInfoConfig)>, -} - -/// Build the [`RDidPlan`] on the loop thread. For BIP32 this reserves two unique -/// derivation paths and advances `path_pointer` (the only `Config` mutation the -/// pre-send step makes); for VTA it just snapshots the owned client/backend. -pub(crate) fn plan_relationship_did( - config: &mut Config, - mediator: &str, - admin_vta: Option<&vta_sdk::client::VtaClient>, -) -> Result { - match &config.key_backend { - KeyBackend::Bip32 { seed, .. } => { - // `ExtendedSigningKey` (ed25519-dalek-bip32 0.3) is not `Clone`, so - // reconstruct an owned root from the persisted seed — the same - // derivation the load path uses, and a pure function of the seed, so - // the rebuilt root is identical to the live one. - let root = Box::new( - ed25519_dalek_bip32::ExtendedSigningKey::from_seed( - &base64::prelude::BASE64_URL_SAFE_NO_PAD - .decode(secrecy::ExposeSecret::expose_secret(seed)) - .map_err(|e| anyhow::anyhow!("couldn't decode BIP32 seed: {e}"))?, - ) - .map_err(|e| anyhow::anyhow!("couldn't rebuild BIP32 root: {e}"))?, - ); - let v_path = format!("m/3'/1'/1'/{}'", config.private.relationships.path_pointer); - config.private.relationships.path_pointer += 1; - let e_path = format!("m/3'/1'/1'/{}'", config.private.relationships.path_pointer); - config.private.relationships.path_pointer += 1; - Ok(RDidPlan::Bip32 { - root, - v_path, - e_path, - mediator: mediator.to_string(), - }) - } - KeyBackend::Vta { .. } => { - // The runtime always holds an admin VTA session for a VTA backend - // (built at startup). If it doesn't, R-DID creation can't proceed — - // surface that up front rather than parking the loop to open one. - let admin_vta = admin_vta.cloned().ok_or_else(|| { - anyhow::anyhow!("no VTA session available to create a relationship DID") - })?; - Ok(RDidPlan::Vta { - admin_vta, - mediator: mediator.to_string(), - }) - } - } -} - -impl RDidPlan { - fn mediator(&self) -> &str { - match self { - RDidPlan::Bip32 { mediator, .. } | RDidPlan::Vta { mediator, .. } => mediator, - } - } - - /// I/O-only relationship-DID creation, run inside the spawned task. Creates - /// the keys (BIP32 derivation or VTA round-trip), builds the did:peer, - /// registers the secrets in the shared TDK resolver, **and registers the new - /// R-DID listener** (built from the just-minted secrets — no resolver lookup, - /// no `Config`). Returns the `Config` `key_info` entries for the loop thread - /// to record. Never touches `Config`. - /// - /// `service` + `remote_p_did` are only used to register the post-establishment - /// R-DID listener, exactly as the old inline path did right after key creation. - pub(crate) async fn create_io( - self, - tdk: &TDK, - service: &Messaging, - remote_p_did: &str, - ) -> Result { - let mediator = self.mediator().to_string(); - // Mint the keys and capture each one's *source material* (BIP32 path or - // VTA key id) in verification-then-encryption order. Note we do NOT record - // the secret ids yet: a freshly generated `Secret` carries a placeholder id - // (`generate_ed25519` → a random base64 string, `generate_x25519` → - // `"x25519"`), and `generate_did_peer_from_secrets` below rewrites those - // ids to the did:peer verification-method ids. `key_info` must be built - // from the *rewritten* ids — see the comment on that step. - let (mut v_secret, mut e_secret, v_source, e_source) = match self { - RDidPlan::Bip32 { - root, - v_path, - e_path, - .. - } => { - let v_key = root.derive(&v_path.parse::()?)?; - let e_key = root.derive(&e_path.parse::()?)?; - let v_secret = Secret::generate_ed25519(None, Some(v_key.signing_key.as_bytes())); - let e_secret = Secret::generate_x25519( - None, - Some(&ed25519_private_to_x25519(e_key.signing_key.as_bytes())), - )?; - // NOTE: v_key/e_key hold BIP32-derived signing-key bytes on the - // stack; ed25519-dalek-bip32 does not Zeroize, a known limitation. - drop(v_key); - drop(e_key); - ( - v_secret, - e_secret, - KeySourceMaterial::Derived { path: v_path }, - KeySourceMaterial::Derived { path: e_path }, - ) - } - RDidPlan::Vta { admin_vta, .. } => { - let (v_secret, e_secret, sign_key_id, enc_key_id) = - create_relationship_keys(&admin_vta).await?; - ( - v_secret, - e_secret, - KeySourceMaterial::VtaManaged { - key_id: sign_key_id, - }, - KeySourceMaterial::VtaManaged { key_id: enc_key_id }, - ) - } - }; - - // Build the did:peer from the secrets. This REWRITES each secret's `id` to - // the did:peer verification-method id (`{did}#key-1` for verification, - // `{did}#key-2` for the key-agreement key). - let mut keys = vec![ - (PeerKeyRole::Verification, &mut v_secret), - (PeerKeyRole::Encryption, &mut e_secret), - ]; - let r_did = DID::generate_did_peer_from_secrets(&mut keys, Some(mediator.clone())) - .map_err(|e| anyhow::anyhow!("Failed to create relationship DID: {e}"))?; - drop(keys); - - // Record `key_info` under the FINAL did:peer-relative secret ids (set by - // the call above), pairing each with its source material. This MUST run - // after the did:peer mint: the reload path (`Relationships:: - // generate_profiles`) only re-registers a stored secret when its id - // `starts_with` the R-DID, and packs DIDComm as the R-DID using the - // `#key-1`/`#key-2` verification-method ids. Persisting the pre-mint - // placeholder ids (the historical bug) left the reloaded R-DID with no - // usable key-agreement secret, so mediator authentication looped forever - // ("sender has no usable key agreement key"). - let key_info: Vec<(String, KeyInfoConfig)> = vec![ - ( - v_secret.id.clone(), - KeyInfoConfig { - path: v_source, - create_time: Utc::now(), - purpose: KeyTypes::RelationshipVerification, - }, - ), - ( - e_secret.id.clone(), - KeyInfoConfig { - path: e_source, - create_time: Utc::now(), - purpose: KeyTypes::RelationshipEncryption, - }, - ), - ]; - - // Register the new R-DID listener from the just-minted secrets (the old - // inline path did this right after key creation; failure is non-fatal). - let listener_config = super::didcomm::relationship_listener_config_from_secrets( - &r_did, - remote_p_did, - &mediator, - vec![v_secret.clone(), e_secret.clone()], - ); - if let Err(e) = super::didcomm::add_listener(service, &listener_config).await { - tracing::warn!(did = %r_did, error = %e, "failed to add R-DID listener"); - } - - // Register the secrets in the shared TDK resolver (visible to the loop's - // TDK — same `Arc`-backed resolver). - tdk.get_shared_state() - .secrets_resolver() - .insert(v_secret) - .await; - tdk.get_shared_state() - .secrets_resolver() - .insert(e_secret) - .await; - - Ok(CreatedRDid { r_did, key_info }) - } -} - -/// Create a relationship's signing (Ed25519) + encryption (X25519) keys via the -/// VTA and fetch their secrets, on the given (admin) session. Returns -/// `(v_secret, e_secret, signing_key_id, encryption_key_id)`. -async fn create_relationship_keys( - client: &vta_sdk::client::VtaClient, -) -> Result<(Secret, Secret, String, String)> { - use vta_sdk::client::CreateKeyRequest; - use vta_sdk::keys::KeyType; - - info!("creating Ed25519 signing key via VTA..."); - let sign_resp = vta::vta_retry("create relationship signing key", || { - client.create_key(CreateKeyRequest { - // Absent is today's behaviour. `Some(true)` mints a - // non-extractable key that cannot be recovered from - // the mnemonic or any backup — never a default. - internal: None, - key_type: KeyType::Ed25519, - derivation_path: None, - key_id: None, - mnemonic: None, - label: Some("relationship-signing".to_string()), - context_id: None, - }) - }) - .await - .map_err(|e| anyhow::anyhow!("Failed to create signing key: {e}"))?; - let sign_secret_resp = vta::vta_retry("get relationship signing key secret", || { - client.get_key_secret(&sign_resp.key_id) - }) - .await - .map_err(|e| anyhow::anyhow!("Failed to get signing key secret: {e}"))?; - let mut v_secret = vta_sdk::did_key::secret_from_key_response(&sign_secret_resp) - .map_err(|e| anyhow::anyhow!("{e:?}"))?; - v_secret.id = v_secret.get_public_keymultibase()?; - - info!("creating X25519 encryption key via VTA..."); - let enc_resp = vta::vta_retry("create relationship encryption key", || { - client.create_key(CreateKeyRequest { - // Absent is today's behaviour. `Some(true)` mints a - // non-extractable key that cannot be recovered from - // the mnemonic or any backup — never a default. - internal: None, - key_type: KeyType::X25519, - derivation_path: None, - key_id: None, - mnemonic: None, - label: Some("relationship-encryption".to_string()), - context_id: None, - }) - }) - .await - .map_err(|e| anyhow::anyhow!("Failed to create encryption key: {e}"))?; - let enc_secret_resp = vta::vta_retry("get relationship encryption key secret", || { - client.get_key_secret(&enc_resp.key_id) - }) - .await - .map_err(|e| anyhow::anyhow!("Failed to get encryption key secret: {e}"))?; - let mut e_secret = vta_sdk::did_key::secret_from_key_response(&enc_secret_resp) - .map_err(|e| anyhow::anyhow!("{e:?}"))?; - e_secret.id = e_secret.get_public_keymultibase()?; - - Ok((v_secret, e_secret, sign_resp.key_id, enc_resp.key_id)) -} - /// Insert a brand-new contact for `did` with `alias`, mirroring the insert tail /// of `ProtectedConfig::add_contact` (the DID-resolution validation and the /// duplicate-alias check both already happened — resolution in the task, the @@ -400,23 +109,6 @@ async fn create_request_message( Ok(msg) } -/// The key that proves control of `our_did` for a handshake: the persona's own -/// authentication key when `our_did` is the persona, else the R-DID's signing -/// key from the TDK's resolver. -pub(crate) async fn relationship_did_signer( - tdk: &TDK, - our_did: &str, - persona_did: &str, - persona_auth: &Secret, -) -> Result { - if our_did == persona_did { - return Ok(persona_auth.clone()); - } - openvtc_core::relationships::relationship_signing_secret(tdk, our_did) - .await - .ok_or_else(|| anyhow::anyhow!("the relationship DID's signing key is not loaded")) -} - // ============================================================ // State-handler dispatch wrappers // ============================================================ @@ -479,7 +171,7 @@ pub(crate) struct CreateJob { tdk: TDK, service: Messaging, /// `Some` ⇒ mint an R-DID first; `None` ⇒ use the persona DID directly. - rdid_plan: Option, + rdid_plan: Option, /// Our persona DID (message `from`, and the listener the request is sent via). persona_did: Arc, /// The persona's authentication key, which signs the DID binding. @@ -566,8 +258,13 @@ impl CreateJob { // 2. Build + send the request via the persona listener (handshake uses // persona DIDs for routing; the R-DID is carried in the body). let msg = match async { - let did_signer = - relationship_did_signer(&tdk, &our_did, &persona_did, &persona_auth).await?; + let did_signer = openvtc_core::relationships::keys::relationship_did_signer( + &tdk, + &our_did, + &persona_did, + &persona_auth, + ) + .await?; create_request_message( &msg_id, &persona_did, @@ -1269,7 +966,9 @@ async fn prepare_submit( // mutation; the VTA round-trip / derivation runs in the task). let rdid_plan = if generate_r_did { let mediator = config.mediator_did().to_string(); - Some(plan_relationship_did(config, &mediator, admin_vta)?) + Some(openvtc_core::relationships::keys::plan_relationship_did( + config, &mediator, admin_vta, + )?) } else { None }; @@ -1783,6 +1482,7 @@ mod tests { use super::*; use crate::state_handler::dispatch_util::test_config; + use openvtc_core::config::{KeyTypes, secured_config::KeySourceMaterial}; /// Build a relationship record (persona DID used as our_did, so it is *not* /// an R-DID) and register it in `config`. Returns the shared Arc. diff --git a/openvtc/src/state_handler/repos_actions.rs b/openvtc/src/state_handler/repos_actions.rs index b429d605..6c895f36 100644 --- a/openvtc/src/state_handler/repos_actions.rs +++ b/openvtc/src/state_handler/repos_actions.rs @@ -59,16 +59,9 @@ pub(crate) fn reply_window(purpose: &Purpose) -> Duration { } } -/// A `git-ns` answer as it arrived: who sent it and who the document says -/// issued it, besides the thread it answers. -pub(crate) struct InboundReply { - /// The transport-authenticated sender. - pub(crate) from: String, - /// The document's `issuer`. - pub(crate) issuer: Option, - pub(crate) thid: String, - pub(crate) reply: Reply, -} +/// A `git-ns` answer as it arrived, threaded on the request it answers +/// ([`openvtc_core::inbound::ThreadedReply`]). +pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; /// The DID part of a DID or DID URL. fn did_part(did: &str) -> &str { diff --git a/openvtc/src/state_handler/rooms_actions.rs b/openvtc/src/state_handler/rooms_actions.rs index 75291340..a0af102c 100644 --- a/openvtc/src/state_handler/rooms_actions.rs +++ b/openvtc/src/state_handler/rooms_actions.rs @@ -53,15 +53,9 @@ const KEYS: DispatchDomain = DispatchDomain::RoomsVta; /// How long a request waits for the community's answer (R1.2). pub(crate) const REPLY_WINDOW: Duration = Duration::from_secs(30); -/// A `vtc/rooms/*` answer as it arrived. -pub(crate) struct InboundReply { - /// The transport-authenticated sender. - pub(crate) from: String, - /// The document's `issuer`. - pub(crate) issuer: Option, - pub(crate) thid: String, - pub(crate) reply: Reply, -} +/// A `vtc/rooms/*` answer as it arrived, threaded on the request it answers +/// ([`openvtc_core::inbound::ThreadedReply`]). +pub(crate) type InboundReply = openvtc_core::inbound::ThreadedReply; fn did_part(did: &str) -> &str { did.split('#').next().unwrap_or(did) diff --git a/openvtc/src/state_handler/setup_sequence/vta.rs b/openvtc/src/state_handler/setup_sequence/vta.rs index 71878eb9..b6a67d9a 100644 --- a/openvtc/src/state_handler/setup_sequence/vta.rs +++ b/openvtc/src/state_handler/setup_sequence/vta.rs @@ -6,9 +6,6 @@ use affinidi_tdk::secrets_resolver::secrets::Secret; use anyhow::Result; use chrono::Utc; use openvtc_core::config::{KeyInfo, PersonaDIDKeys, secured_config::KeySourceMaterial}; -use std::future::Future; -use std::time::Duration; -use tracing::warn; use vta_sdk::{ client::{CreateDidWebvhRequest, CreateKeyRequest, DidPresentation, VtaClient}, error::VtaError, @@ -18,59 +15,7 @@ use vta_sdk::{ webvh::WebvhServerRecord, }; -/// Max attempts for a single VTA round-trip before giving up (1 try + 2 retries). -const VTA_MAX_ATTEMPTS: usize = 3; - -/// Base back-off between retries; doubles each attempt (0.5s, then 1s). Short, -/// because the dominant retryable fault is a stale DIDComm socket that ATM -/// re-establishes almost immediately — we just need a beat before re-sending. -const VTA_RETRY_BASE: Duration = Duration::from_millis(500); - -/// True for errors a retry might clear: transport/timeout faults where the -/// request most likely never reached the VTA (or its reply never came back). -/// -/// The motivating case is a stale always-on DIDComm session — the mediator -/// dropped the idle WebSocket, the first `send_and_wait` packed into a dead -/// socket and timed out, but ATM auto-reconnects underneath, so the *next* send -/// lands on a live socket and succeeds. REST `Network` and 5xx `Server` errors -/// are transient the same way. Deterministic faults (validation, conflict, -/// not-found, auth, gone) are never retried — re-sending can't change them. -fn vta_retryable(e: &VtaError) -> bool { - matches!( - e, - VtaError::DidcommTransport(_) | VtaError::Network(_) | VtaError::Server { .. } - ) -} - -/// Run a single VTA round-trip with bounded retry on transient transport faults -/// (see [`vta_retryable`]). `op` is re-invoked from scratch each attempt, so it -/// must rebuild any by-value request — and the caller must be content with a -/// possible duplicate on the rare "VTA processed it but the reply was lost" -/// timeout. That's a non-issue for reads (`get_key_secret`, `list_*`) and cheap -/// for `create_key` (at worst an orphan key); the join flow already rolls back a -/// half-minted persona. `label` names the op for the retry log line. -pub(crate) async fn vta_retry(label: &str, mut op: F) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut attempt = 1; - loop { - match op().await { - Ok(v) => return Ok(v), - Err(e) if attempt < VTA_MAX_ATTEMPTS && vta_retryable(&e) => { - let backoff = VTA_RETRY_BASE * (1 << (attempt - 1)); - warn!( - "VTA '{label}' failed (attempt {attempt}/{VTA_MAX_ATTEMPTS}): {e} — \ - retrying in {backoff:?}" - ); - tokio::time::sleep(backoff).await; - attempt += 1; - } - Err(e) => return Err(e), - } - } -} +pub(crate) use openvtc_core::vta_task::retry as vta_retry; /// The message prefix every TSP reply-timeout carries, from the SDK's own /// `TSP_REPLY_TIMEOUT_PREFIX`. Matched as a literal because the SDK keeps both @@ -575,79 +520,6 @@ pub async fn create_did_via_server( #[cfg(test)] mod tests { use super::*; - use std::cell::Cell; - - #[test] - fn retryable_covers_transport_and_5xx_only() { - // Transient transport / server faults — retry may clear them. - assert!(vta_retryable(&VtaError::DidcommTransport("timeout".into()))); - assert!(vta_retryable(&VtaError::Server { - status: 503, - body: String::new(), - })); - // Deterministic faults — re-sending can't change the outcome. - assert!(!vta_retryable(&VtaError::Validation("bad".into()))); - assert!(!vta_retryable(&VtaError::Conflict("dup".into()))); - assert!(!vta_retryable(&VtaError::NotFound("gone".into()))); - assert!(!vta_retryable(&VtaError::Auth("expired".into()))); - } - - #[tokio::test(start_paused = true)] - async fn returns_immediately_on_success() { - let calls = Cell::new(0u32); - let out: Result = vta_retry("ok", || { - calls.set(calls.get() + 1); - async { Ok(7) } - }) - .await; - assert_eq!(out.unwrap(), 7); - assert_eq!(calls.get(), 1, "no retry when the first attempt succeeds"); - } - - #[tokio::test(start_paused = true)] - async fn retries_transient_then_succeeds() { - // Models a stale socket: first send times out, ATM reconnects, second - // send lands. The op should be retried and ultimately succeed. - let calls = Cell::new(0u32); - let out: Result = vta_retry("stale-then-ok", || { - let n = calls.get() + 1; - calls.set(n); - async move { - if n < 2 { - Err(VtaError::DidcommTransport("stale socket".into())) - } else { - Ok(9) - } - } - }) - .await; - assert_eq!(out.unwrap(), 9); - assert_eq!(calls.get(), 2); - } - - #[tokio::test(start_paused = true)] - async fn gives_up_after_max_attempts() { - let calls = Cell::new(0u32); - let out: Result = vta_retry("always-down", || { - calls.set(calls.get() + 1); - async { Err(VtaError::DidcommTransport("down".into())) } - }) - .await; - assert!(out.is_err()); - assert_eq!(calls.get(), VTA_MAX_ATTEMPTS as u32); - } - - #[tokio::test(start_paused = true)] - async fn does_not_retry_deterministic_error() { - let calls = Cell::new(0u32); - let out: Result = vta_retry("validation", || { - calls.set(calls.get() + 1); - async { Err(VtaError::Validation("nope".into())) } - }) - .await; - assert!(out.is_err()); - assert_eq!(calls.get(), 1, "deterministic faults are not retried"); - } /// The failure that cost an hour of log-reading: the VTA relayed the /// mint onward to the DID hosting server, the hosting server answered diff --git a/openvtc/src/state_handler/verify_queue.rs b/openvtc/src/state_handler/verify_queue.rs index 4b40c537..1add784a 100644 --- a/openvtc/src/state_handler/verify_queue.rs +++ b/openvtc/src/state_handler/verify_queue.rs @@ -2,7 +2,7 @@ //! //! A message whose handling needs a network-bound check (a DID resolve, a //! status-list fetch) is set aside by dispatch -//! ([`super::message_dispatch::InboundEffects::deferred`]) and queued here. One +//! ([`openvtc_core::inbound::InboundEffects::deferred`]) and queued here. One //! check runs at a time, spawned as its own task, and the message comes back //! with the result ([`Verifier::finished`]) to be dispatched from the top. //! @@ -37,7 +37,7 @@ use tokio::task::JoinHandle; use tracing::warn; use super::didcomm::MessagingTransport; -use super::message_dispatch::{Deferred, PreVerified}; +use openvtc_core::inbound::{Deferred, PreVerified}; /// The longest one check may take: a DID resolve and a status-list fetch are /// each bounded at ten seconds (R1.2), so this is their sum with room to spare. @@ -278,9 +278,9 @@ impl Verifier { #[cfg(test)] mod tests { - use super::super::message_dispatch::VerifyJob; use super::*; use crate::state_handler::dispatch_util::test_tdk; + use openvtc_core::inbound::VerifyJob; fn barrier(id: &str) -> Deferred { Deferred { diff --git a/openvtc/src/state_handler/vetting_actions/applicant.rs b/openvtc/src/state_handler/vetting_actions/applicant.rs index 0163ec70..c373c071 100644 --- a/openvtc/src/state_handler/vetting_actions/applicant.rs +++ b/openvtc/src/state_handler/vetting_actions/applicant.rs @@ -15,7 +15,6 @@ use super::dispatch::{back, page, persist, status}; use super::jobs::Sent; use super::send::{abandon, begin, persona_did, sign_and_send}; use super::words::community_display; -use crate::state_handler::join_flow; use crate::state_handler::main_page::content::{ DIRECTORY_LABELS, DIRECTORY_METHODS, DirectoryView, VettingMode, VettingTab, row_of, }; @@ -181,7 +180,7 @@ pub(super) fn refresh_application_contexts(ctx: &mut ActionCtx<'_>) { }], None => { let record = persona.and_then(|p| config.account.personas.get(&p)); - let suggested = join_flow::suggested_context(config, &community); + let suggested = openvtc_core::join::sequence::suggested_context(config, &community); community_context::context_options(&config.account, record, &suggested) } }; diff --git a/openvtc/src/state_handler/vetting_actions/faces.rs b/openvtc/src/state_handler/vetting_actions/faces.rs index 4439ba3e..de3a9c3e 100644 --- a/openvtc/src/state_handler/vetting_actions/faces.rs +++ b/openvtc/src/state_handler/vetting_actions/faces.rs @@ -17,7 +17,6 @@ use super::jobs::{CardJob, CardStep, FaceJob, Presenting}; use super::outcome::VettingOutcome; use super::send::{abandon, begin, resolver}; use crate::state_handler::background_dispatch::{self, DispatchDomain, DispatchOutcome}; -use crate::state_handler::join_flow; use crate::state_handler::main_page::content::{ CardPreview, FaceChoice, NewFaceForm, NewFaceStep, PoolRow, VettingMode, }; @@ -306,7 +305,7 @@ pub(super) fn application_context( .filter(|home| community_context::is_sub_context(home, top)) { Some(home) => home.to_string(), - None => join_flow::suggested_context(config, &app.community.clone()), + None => openvtc_core::join::sequence::suggested_context(config, &app.community.clone()), }; if let Some(app) = config.private.vetting.application_by_id_mut(application_id) { app.context_id = Some(id.clone()); diff --git a/openvtc/tests/inbound_gate_census.rs b/openvtc/tests/inbound_gate_census.rs deleted file mode 100644 index 06c6ae68..00000000 --- a/openvtc/tests/inbound_gate_census.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Every type the inbound dispatcher routes on must be admitted by the gate. -//! -//! ## The failure this closes -//! -//! `openvtc_core::didcomm` drops any inbound message whose `type` is not matched -//! by `OPENVTC_CATCH_ALL_PATTERN`, with a `debug!` and no reply. Not a refusal — -//! a *drop*. The sender sees no error, no problem-report, and nothing but a wait -//! that never ends. -//! -//! So a handler added to `process_inbound_message` without a matching prefix in -//! that pattern is a verb that fails totally and silently. That is not -//! hypothetical: `capabilities::send_capability_document` sent the binding -//! envelope, `message_dispatch` had a branch for exactly that type, and every -//! reply was dropped between them. Nothing was red; the symptom was capability -//! writes that appeared to go unacknowledged. -//! -//! The gate and the dispatcher are two descriptions of one fact — "which types -//! does this client handle" — and two descriptions agree right up until someone -//! edits one. This test is the check that they still do. -//! -//! ## Why a list and not something cleverer -//! -//! The honest fix is a registry the gate is *derived* from, so the two cannot -//! differ at all — the direction `vtc-service` took for its `rooms/*` family, -//! where `registered_uris()` is the served list rather than a copy of one. -//! `process_inbound_message` cannot go there yet: it threads `&mut Config` and -//! `&mut InboundEffects` through every arm, and a type-keyed dispatcher requires -//! handlers whose futures are `'static`, so the change is to the app's state -//! model rather than to its routing. -//! -//! Until then this list is the seam. It is deliberately written out rather than -//! generated, because the thing being checked is exactly "did someone add a -//! branch and not tell the gate" — and a list that derived itself from the -//! branches would answer yes by construction and prove nothing. - -use openvtc_core::didcomm::routes_inbound_type; - -/// Every `type` `process_inbound_message` compares against, in the order it -/// tests them. -/// -/// When you add a branch there, add it here. The test below is what turns -/// forgetting into a failure instead of a silence. -fn routed_types() -> Vec<&'static str> { - vec![ - openvtc_core::protocol_urls::TRUST_PONG, - openvtc_core::capabilities::TRUST_TASK_ENVELOPE_TYPE, - vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RECEIPT_TYPE, - vta_sdk::protocols::join_requests::JOIN_REQUEST_SUBMIT_RESPONSE_TYPE, - vta_sdk::protocols::join_requests::JOIN_REQUEST_STATUS_RESPONSE_TYPE, - vta_sdk::protocols::credential_exchange::ISSUE, - vta_sdk::protocols::PROBLEM_REPORT_TYPE, - openvtc_core::personhood::PERSONHOOD_CHALLENGE_RESPONSE_TYPE, - openvtc_core::personhood::PERSONHOOD_ASSERT_RESPONSE_TYPE, - openvtc_core::renewal::MEMBERS_RENEW_RESPONSE_TYPE, - vta_sdk::protocols::members::MEMBER_VMC_RESPONSE_TYPE, - vta_sdk::protocols::members::MEMBER_REQUEST_VMC_TYPE, - vta_sdk::protocols::members::MEMBER_REMOVAL_NOTICE_TYPE, - openvtc_core::join::COMMUNITY_PROFILE_SHOW_RESPONSE_TYPE, - // Capability replies in the document's own type, routed by prefix - // (`is_capability_reply_type`). - "https://trusttasks.org/spec/governance/capability/list/0.1#response", - "https://trusttasks.org/spec/governance/capability/enable/0.1#response", - // Git namespace replies, routed by prefix (`git_ns::is_reply_type`). - // TSP opens the binding envelope before the gate, so these arrive - // typed as the task; missing from the gate, every Repos answer to a - // TSP-joined member was dropped and the view timed out. - "https://trusttasks.org/spec/git-ns/view/0.6#response", - "https://trusttasks.org/spec/git-ns/repo/create/0.4#response", - "https://trusttasks.org/spec/git-ns/right/grant/0.4#response", - "https://trusttasks.org/spec/git-ns/account/link/0.1#response", - "https://trusttasks.org/spec/git-ns/drift/resolve/0.4#response", - // Data-room replies for the Rooms panel, routed by prefix - // (`community_rooms::is_reply_type`). - "https://trusttasks.org/spec/vtc/rooms/memberships/list/0.2#response", - "https://trusttasks.org/spec/vtc/rooms/offers/list/0.4#response", - "https://trusttasks.org/spec/vtc/rooms/offers/accept/0.1#response", - // Election replies for the Elections panel, routed by prefix - // (`elections::is_reply_type`). - "https://trusttasks.org/spec/vtc/elections/list/0.5#response", - "https://trusttasks.org/spec/vtc/elections/show/0.6#response", - "https://trusttasks.org/spec/vtc/elections/tickets/issue/0.3#response", - // Notifications addressed to a persona, handed to the member's VTA - // (`notifications::is_notification_type`, VTI-NTF-054). - openvtc_core::notifications::NOTIFY_TYPE, - openvtc_core::notifications::RELAY_TYPE, - // A witness's answers, routed by `witness::is_reply_type`. - "https://trusttasks.org/spec/witness/session/0.2#response", - "https://trusttasks.org/spec/witness/session/submit/0.2#response", - ] -} - -/// **The census.** Every routed type reaches the handler. -#[test] -fn every_routed_type_is_admitted_by_the_gate() { - let mut dropped = Vec::new(); - for uri in routed_types() { - if !routes_inbound_type(uri) { - dropped.push(uri); - } - } - - assert!( - dropped.is_empty(), - "these types are routed by `process_inbound_message` but dropped by the \ - inbound gate before they reach it. A dropped message produces no error \ - and no reply — the verb simply never works, and nothing goes red. Add \ - the prefix to `OPENVTC_CATCH_ALL_PATTERN`:\n {}", - dropped.join("\n ") - ); -} - -/// The trust-task error family, which the dispatcher matches by *prefix* rather -/// than by an exact constant (`is_trust_task_error_type`). Every version has to -/// pass the gate, not just the one that happens to be current. -#[test] -fn every_trust_task_error_version_is_admitted() { - for uri in [ - "https://trusttasks.org/spec/trust-task-error/0.1", - "https://trusttasks.org/spec/trust-task-error/0.2", - "https://trusttasks.org/spec/trust-task-error/0.5", - "https://trusttasks.org/spec/trust-task-error/9.9", - ] { - assert!( - routes_inbound_type(uri), - "{uri} is refused by the gate — a peer on a different framework \ - version would have its refusals silently dropped, which reads as a \ - request that was never answered" - ); - } -} - -/// The gate is a routing decision, not a catch-everything: something this client -/// does not handle must still be refused, or the census above would be -/// satisfied by a pattern that matched the world. -#[test] -fn the_gate_still_refuses_what_this_client_does_not_route() { - for uri in [ - "https://trusttasks.org/spec/acl/list/0.1", - "https://trusttasks.org/spec/policy/upsert/0.2", - "https://example.com/whatever", - ] { - assert!( - !routes_inbound_type(uri), - "{uri} must not be routed here — if the gate now admits it, the \ - census above proves nothing" - ); - } -} diff --git a/tasks/follow-ups.md b/tasks/follow-ups.md index e6dfe27f..a14006bf 100644 --- a/tasks/follow-ups.md +++ b/tasks/follow-ups.md @@ -116,7 +116,7 @@ routes no longer exist. The current document endpoint resolves the proof's the vm-resolver work. Fix that comment when this is done. ### [x] Verify VTC trust-task reply proofs -Done: `verification_job` in `openvtc/src/state_handler/message_dispatch.rs` +Done: `verification_job` in `openvtc-core/src/inbound/mod.rs` routes join verdicts, receipts and status replies, removal notices, profile, capability, git-ns, rooms and election replies through `VerifyJob::Operational` (`openvtc_core::operational::verify_operational`); handlers refuse an unchecked