feat(join): read a community's requirements from the endpoint it publishes - #344
Merged
Merged
Conversation
…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
force-pushed
the
feat/manifest-over-rest
branch
from
September 18, 2026 12:39
1fe5d1e to
f758551
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
VTCRestservice in its DID document, and
POST {endpoint}/v1/trust-tasksdispatches onthe document's
type. Confirmed against a live community — no auth, nosignature, rate-limited and body-capped server side, reply signed by the VTC:
Nothing in OpenVTC read
VTCRestat all.What changed
openvtc-core/src/vetting/discover.rsreads the manifest there, andenter_communityasks there first, falling back to the DIDComm question. So therequirements 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 namesnobody, 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_urlis shared (madepub(crate)) rather than copied, so there staysone 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_withand the proven signer must bethe community asked. A claimed
issueris not enough, and neither is TLS to ahost the DID document happened to name.
DiscoverErrorkeeps 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— cleancargo test --all-features -- --include-ignored— all greenRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --all-features— cleanUnit tests cover the
VTCRestlookup in all three shapes a DID document maywrite (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 ratherthan its envelope, and every
DiscoverErrorreading differently from the others.Verified live against
first.openvtc.netduring development — endpointresolved from the DID document, manifest fetched, proof verified against the
did:webvhsigner,vetted-member/minStatements: 1returned. Those checksare 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
recipientis not itself (422 document recipient does not identify this consumer). So an honest server cannot be made to answer foranother community — the
WrongSignercheck is the guard against a dishonestendpoint, which is exactly the case the server-side check cannot cover.
Policy note
The join flow dials under
ProbePolicy::PublicOnly. A development stack onloopback is therefore not read over REST and falls back to the DIDComm question,
which is the safe default; the
--allow-private-probesescape hatch exists onlyon
openvtc healthtoday.