Skip to content

feat: assert personhood to a community over Trust Tasks - #257

Merged
stormer78 merged 3 commits into
mainfrom
feat/personhood-assert
Aug 24, 2026
Merged

feat: assert personhood to a community over Trust Tasks#257
stormer78 merged 3 commits into
mainfrom
feat/personhood-assert

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

The member side of the VTC's personhood ceremony. Pairs with OpenVTC/verifiable-trust-infrastructure#1085 (merged) and its follow-up, which routes the two verbs over messaging — before that they were REST-only, so a client that talks to communities over DIDComm and TSP and holds no bearer token could not reach them at all. Personhood appeared in this repo only as README prose.

What lands

openvtc-core::personhoodrequest_challenge, build_presentation, assert_personhood, and parsers for both replies.

Both verbs ride the same Trust Task document path as the join ceremony: DIDComm wraps the document in an authcrypt envelope, TSP carries it bare, and the sender is cryptographically proven either way. Routing needed no change — OPENVTC_CATCH_ALL_PATTERN already admits https://trusttasks.org/spec/vtc/.*.

Route groups the transport plumbing both verbs need. Six same-typed DID strings passed loose is one transposed pair away from addressing the wrong party.

The spoken match code

challengeId is a UUID: fine on a wire, hopeless read aloud. match_code derives eight Crockford base32 characters from it — no I/L/O/U, so nothing in one is confusable when spoken. This is what two people in a room actually say to each other.

It is derived, never transferred: anyone holding the challenge id computes the same characters, anyone who does not cannot. Nothing on the VTC checks it, because it proves nothing proof.challenge does not already prove. It exists so a human can tell that the ceremony their client is answering is the one the administrator in front of them just started — a Bluetooth pairing code, not a password.

match_code_agrees_with_the_vtc pins the vector 5CY1-GZEE; the VTC pins the same one. Nothing else would catch the two derivations drifting — a changed domain tag or alphabet on either side still yields eight plausible characters that simply never match, which reads to the people in the room as "wrong ceremony" rather than "your software disagrees with itself". parse_challenge_reply turns a live disagreement into an error for the same reason.

The challenge is written twice, deliberately

assert/0.1 says proof.challenge must be the paired challengeId, and that this is what "stops one captured and replayed into another". In W3C Data Integrity that holds because the proof options are canonicalised with the document, so challenge is signed.

affinidi_data_integrity has no challenge proof option, and the VTC verifies over the presentation with the whole proof block removed. A value written only there is not covered by the signature — swapping it on a captured presentation would go unnoticed.

So build_presentation writes the challenge to proof.challenge (what the spec names) and to top-level nonce (what the signature covers), and the VTC requires both to agree. the_signed_nonce_is_covered_by_the_proof pins that tampering with nonce breaks verification — which is the whole reason it carries the challenge, and why neither copy can be dropped as redundant.

Not in this PR

No CLI or TUI command yet — this is the library surface the command will call. cargo test -p openvtc-core --lib is green (437 tests, 9 of them new); clippy and fmt clean.

A community that vets its members can mark them as people; the claim lands
as a `PersonhoodCredential` type on the member's VMC, which is what DTG
Credentials means by a PHC. `openvtc` had no way to take part — personhood
appeared only in README prose, and the VTC's verbs were reachable over REST
alone, which a client that talks to communities over DIDComm and TSP and
holds no bearer token cannot use.

The two member-facing verbs now ride the same Trust Task document path as
the join ceremony: DIDComm wraps the document in an authcrypt envelope, TSP
carries it bare, and the sender is cryptographically proven either way.
`Route` groups the transport plumbing both verbs need — six same-typed DID
strings passed loose is one transposed pair away from addressing the wrong
party.

## The spoken match code

`challengeId` is a UUID: fine on a wire, hopeless read aloud. `match_code`
derives eight Crockford base32 characters from it (no I/L/O/U, so nothing in
one is confusable when spoken) — the thing two people in a room actually say
to each other.

It is derived, never transferred: anyone holding the challenge id computes
the same characters, anyone who does not cannot. Nothing on the VTC checks
it, because it proves nothing `proof.challenge` does not already prove. It
exists so a human can tell that the ceremony their client is answering is the
one the administrator in front of them just started.

`match_code_agrees_with_the_vtc` pins the vector `5CY1-GZEE`, and the VTC
pins the same one. Nothing else would catch the two derivations drifting: a
changed domain tag or alphabet on either side still yields eight plausible
characters that simply never match, which reads to the people in the room as
"wrong ceremony" rather than "your software disagrees with itself".

## The challenge is written twice, deliberately

`assert/0.1` says `proof.challenge` must be the paired `challengeId`, and
that this is what "stops one captured and replayed into another". In W3C Data
Integrity that holds because the proof options are canonicalised with the
document, so `challenge` is signed.

`affinidi_data_integrity` has no `challenge` proof option, and the VTC
verifies over the presentation with the whole `proof` block removed. A value
written only there is therefore **not covered by the signature** — swapping
it on a captured presentation would go unnoticed.

So `build_presentation` writes the challenge to `proof.challenge` (what the
spec names) and to top-level `nonce` (what the signature covers), and the VTC
requires both to agree. `the_signed_nonce_is_covered_by_the_proof` pins that
tampering with `nonce` breaks verification — which is the whole reason it
carries the challenge, and the reason neither copy can be dropped as
redundant.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…e disagreement

The two verbs could be sent but not understood: replies arrived as
`#response` documents that nothing parsed, so a member never learned the
challenge id to sign against.

`parse_challenge_reply` and `parse_assert_reply` normalise both reply
shapes the VTC produces — the dispatcher's `#response` document with members
under `payload`, and a hand-built bare body — the same way `messaging`
already does, which also makes them transport-agnostic: a TSP frame carries
the response document raw.

Routing needed no change; `OPENVTC_CATCH_ALL_PATTERN` already admits
`https://trusttasks.org/spec/vtc/.*`.

The challenge parser compares the community's own copy of the match code
against the one derived locally and treats a disagreement as an error. The
two are computed by different implementations, and a drift is otherwise
invisible: both sides still show eight plausible characters, so the people in
the room conclude they have the wrong ceremony rather than that the software
disagrees with itself. A community that sends no copy at all is fine — the
code is derived locally, so an older VTC costs nothing.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@affinidi-appsecurity-bot

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review — all clear. We checked this change and found nothing to report. Keep shipping secure code!

Note: for major, breaking, or feature-introducing changes, you can always request an in-depth review from the security team.

The library surface had no way in. `p` on an Active membership asks the
community for a challenge; the reply lands as the panel's live challenge and
shows the match code; `P` answers it.

The match code gets its own line in the panel's emphasis colour rather than
being folded into the status text — it is the thing a member reads aloud, and
a code that has to be picked out of a sentence is a code that gets misread.

An expired challenge renders as expired rather than disappearing. A member
who has just been read a code, looking at a panel that never mentions one,
cannot tell "it lapsed" from "it never arrived".

Key hints are gated exactly as the handler gates the keys, which is this
file's existing discipline: `p` on Active rows only, `P` only while a live
challenge is in hand. Both would otherwise be dead keys — the defect that
gating was written to remove.

The assertion targets the membership the *challenge* names, not the
highlighted row. Answering against whichever row happens to be selected is
exactly the confusion the stored `vtc_did` exists to prevent. It presents
that membership's own credentials and lets the community's policy choose:
a client-side filter would have to guess at a policy it cannot read, and
guessing wrong withholds the very credential that would have satisfied it.
Scoping to the one membership also keeps one community's credentials from
being disclosed to another.

Two supporting changes:

- `Verb` gained the two personhood sends, so `CommunityOutcome`'s
  `leaving: bool` became a `Performed` enum. A boolean cannot say which of
  four things happened, and the arms that got it wrong would have told the
  member the wrong thing went wrong rather than failed.
- The three out-params of `process_inbound_message` became `InboundEffects`.
  The live challenge belongs to `State`, not the account, so it is reported
  up rather than persisted — a single-use nonce with a ten-minute life has no
  business surviving a restart.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 merged commit 93382f1 into main Aug 24, 2026
14 checks passed
@stormer78
stormer78 deleted the feat/personhood-assert branch August 24, 2026 09:32
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.

2 participants