Skip to content

feat(join): read a community's requirements from the endpoint it publishes - #344

Merged
stormer78 merged 1 commit into
mainfrom
feat/manifest-over-rest
Sep 18, 2026
Merged

stormer78 merged 1 commit into
mainfrom
feat/manifest-over-rest

Conversation

@stormer78

@stormer78 stormer78 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Rebased onto main (now carrying #341, #342, #343); single commit.

The VTC-side companion, verifiable-trust-infrastructure#1563, has merged — a community can now turn public discovery off. This PR handles that case as an ordinary failure: the manifest fetch comes back Refused, and the flow falls through to the DIDComm question exactly as it does for a community that publishes no endpoint at all.

Fixes the "could not learn whether … vets the people who join" screen a first join lands on.

The finding

That screen was accurate about what OpenVTC did and wrong about what was
possible. OpenVTC only ever asked for a community's requirements over DIDComm,
which needs a mediator socket, a persona to ask as, and a loop that can hear the
answer. A first join has none of those — the State-A loop has no inbound arm by
design — so the one screen an applicant most needs filled in was blank exactly
when they had least context.

The same answer is already public over HTTPS. A VTC publishes a VTCRest
service in its DID document, and POST {endpoint}/v1/trust-tasks dispatches on
the document's type. Confirmed against a live community — no auth, no
signature, rate-limited and body-capped server side, reply signed by the VTC:

POST https://first.openvtc.net/v1/trust-tasks
{"id":"urn:uuid:…","type":".../vtc/join-requests/manifest/0.2",
 "recipient":"did:webvh:…first-vtc","payload":{}}
→ 200  vetted-member, minStatements 1, name.legal, requirementsDigest
       signed by did:webvh:…first-vtc#key-0

Nothing in OpenVTC read VTCRest at all.

What changed

openvtc-core/src/vetting/discover.rs reads the manifest there, and
enter_community asks there first, falling back to the DIDComm question. So the
requirements arrive in one round trip everywhere, and arrive at all on a first
join.

The request names nobody. No issuer — the service answers one that names
nobody, and that is the point rather than a convenience: the join page promises
"nothing about you has been sent to {community}", and stamping a persona DID on
a pre-application read would quietly make that false. Reading what a community
asks of applicants must not tell it who is considering applying
(vetting-process.md §6.1, informed non-application).

The endpoint is attacker-influenced, so it is dialled under the guards the
health probe already uses — HTTPS only, no redirects, no proxy, no userinfo, and
a DNS resolver that refuses a name pointing at a non-routable address.
vet_probe_url is shared (made pub(crate)) rather than copied, so there stays
one URL guard.

The answer is trusted only as far as its proof. A manifest is what an
applicant gathers evidence against: a forged one could ask for a passport scan
the real community never wanted, which is the same phishing surface an
unverified agent name is (CLAUDE.md's rule). So the reply's Data-Integrity proof
is verified via verify_trust_task_proof_with and the proven signer must be
the community asked. A claimed issuer is not enough, and neither is TLS to a
host the DID document happened to name.

DiscoverError keeps the failures apart as R6.4 requires — NoEndpoint,
Blocked, Unreachable, Refused, Unreadable, Unproven, WrongSigner
because those have different next steps for the operator, and a single fixed
hint for all of them is what that rule forbids. Every outbound step is bounded
(R1.2).

The guidance, which is the other half

When neither route works the page still appears, and it now says what to do. It
used to state the problem and offer a key, leaving the question the operator
actually has — is joining now a dead end? — to be guessed at.

On a first join it now says joining anyway is the way forward rather than a last
resort, and why: the request is recorded and messaging comes up with it, so this
community can be asked straight afterwards, and if it does vet, the application
can be made then and the join taken up again from it (#343). Where asking
could work it says to ask again instead. The two cases no longer share one
sentence.

Testing

  • cargo fmt --all, cargo clippy --all-targets --all-features — clean
  • cargo test --all-features -- --include-ignored — all green
  • RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features — clean

Unit tests cover the VTCRest lookup in all three shapes a DID document may
write (string type, array type, object endpoint), a community publishing none,
the URL guard refusing plaintext / userinfo / loopback / a non-http scheme, the
request carrying no issuer, a refusal being reported by its message rather
than its envelope, and every DiscoverError reading differently from the others.

Verified live against first.openvtc.net during development — endpoint
resolved from the DID document, manifest fetched, proof verified against the
did:webvh signer, vetted-member / minStatements: 1 returned. Those checks
are deliberately not committed: CI's coverage job runs --include-ignored,
so a live-internet test would fail whenever that development community changes.

One thing that turned up while testing and is worth knowing: the VTC refuses a
document whose recipient is not itself (422 document recipient does not identify this consumer). So an honest server cannot be made to answer for
another community — the WrongSigner check is the guard against a dishonest
endpoint, which is exactly the case the server-side check cannot cover.

Policy note

The join flow dials under ProbePolicy::PublicOnly. A development stack on
loopback is therefore not read over REST and falls back to the DIDComm question,
which is the safe default; the --allow-private-probes escape hatch exists only
on openvtc health today.

…ishes

A community that vets says what it requires before anything about the
applicant is sent. OpenVTC only ever asked that question over DIDComm,
which needs a mediator socket, a persona to ask as, and a loop that can
hear the answer — and a first join has none of those. So the one screen
an applicant most needs filled in was blank exactly when they had least
context, and the flow offered "join anyway" as if that were the end of it.

The same answer is public and served over HTTPS: a VTC publishes a
`VTCRest` service in its DID document, and `POST {endpoint}/v1/trust-tasks`
dispatches on the document type. `vetting::discover::fetch_manifest` reads
it there, and `enter_community` asks there first, falling back to the
DIDComm question. Verified against a live community: the endpoint resolves
out of the DID document, the manifest comes back, and its proof verifies
against the `did:webvh` signer.

The request names nobody — no `issuer`. The join page promises "nothing
about you has been sent", and stamping a persona DID on a pre-application
read would quietly make that false; what a community asks of applicants
must be readable without telling it who is considering applying.

The endpoint is attacker-influenced, so it is dialled under the guards the
health probe already uses — `vet_probe_url` is shared rather than copied.
And the answer is trusted only as far as its proof: a forged manifest could
ask for documents the real community never wanted, which is the same
phishing surface an unverified agent name is. The Data-Integrity proof is
verified and the proven signer must be the community asked; a claimed
`issuer` is not enough, and neither is TLS to a host the document named.

`DiscoverError` keeps the failures apart as R6.4 requires — no endpoint,
blocked URL, unreachable, refused, unreadable, unproven, wrong signer —
because those have different next steps. Every outbound step is bounded
(R1.2).

Finally, the page now says what to do when neither route works. It stated
the problem and offered a key, leaving "is joining now a dead end?" to be
guessed at. On a first join it says joining anyway is the way forward, not
a last resort: the request is recorded and messaging comes up with it, so
the community is askable straight after, and a vetting community's
application can then be made and the join taken up from it. Where asking
could work, it says to ask again instead.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 force-pushed the feat/manifest-over-rest branch from 1fe5d1e to f758551 Compare September 18, 2026 12:39
@stormer78
stormer78 merged commit f5dfb9f into main Sep 18, 2026
15 checks passed
@stormer78
stormer78 deleted the feat/manifest-over-rest branch September 18, 2026 12:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant