Skip to content

fix(consent): derive the approver's match code from the digest, not its encoding - #113

Merged
stormer78 merged 1 commit into
mainfrom
feat/digest-multibase
Aug 9, 2026
Merged

fix(consent): derive the approver's match code from the digest, not its encoding#113
stormer78 merged 1 commit into
mainfrom
feat/digest-multibase

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

Pairs with OpenVTC/verifiable-trust-infrastructure#911 — must land together

Trust Tasks 0.4 moved payloadDigest to the shared DigestMultibase type and landed it errata-style, in place on task-consent/{request,decision,granted}/0.1. The type URI did not move, so no version check on either side detects this — the encoding is the only signal.

The digest is what the approver signs and what the executor re-derives. A mismatched pair produces an approval that is given, accepted by the human, and then silently never takes effect. This should not merge before #911, and #911 should not merge without it.

What was wrong

The approver screen took the first six characters of payloadDigest as the code the human matches across two devices. Under base58btc every SHA-256 digest begins zQm — the multibase marker plus the 0x12 0x20 multihash prefix, constant for every digest ever produced:

zQmcdLJ…   zQmRTnb…   zQmb7oR…   zQmbu6r…      ← four different payloads

Half the code would have been a format marker: ~17.6 bits where the human believes they are comparing ~35, while still looking like six random characters. That is the one check on this screen a hostile device cannot defeat — every other check assumes an honest device; only a comparison the human performs across two independent screens moves the check somewhere the device cannot reach.

What this does

matchCodeFromDigest (new, packages/core/src/trust-tasks/digest.ts) decodes the multibase, strips the multihash prefix, and hex-renders the leading bytes. Because the digest is still SHA-256, this reproduces exactly the six characters this surface showed when the wire carried bare hex — so the migration is invisible to the operator and neither repo strands what the other already has on screen.

It agrees character-for-character with vta_mobile_core::consent::match_code_from_digest in #911. The fixture zQmSK9pGKFnmc77pqyNAPJyPKt8rMqctngfg3vwuMArwGYZ3b0c7f is asserted in both repositories and is the contract between them.

  • Fails closed on an undecodable digest. No code rendered, no type-to-confirm input, approval blocked, and the user is told why. A stale hex digest from a version-skewed agent fails here rather than at the executor after the human has already agreed.
  • Keeps the case-insensitive compare, now correct rather than accidentally so — the derived code is hex. Lowercasing base58 would have widened the match.
  • Accepts every base the ^[zumbfF] pattern admits, so a conforming digest from a non-VTA executor is not refused. An unrecognised prefix is refused rather than guessed at.
  • Reuses the base58btc decoder already in trust-tasks/canonical.ts.
  • payloadDigest is still echoed verbatim into the decision, which #911's round-trip test requires.

9 new tests in packages/core/tests/trust-tasks.digest.mjs, including the shared fixture, the hex(digest)[..6] continuity property, bare-hex refusal, and 64 payloads → 64 distinct codes.

Dependency refresh (same PR, by request)

Everything in range; npm outdated is now empty. react 19.2.8, vite 8.2.1, @scure/base 2.3.0, @noble/* 2.3.0, @types/chrome 0.2.5, lucide-react 1.31.0, and others.

⚠️ vite 8.2 dropped esbuild, and vite-plugin-top-level-await@1.6.0 (already latest) requires it without declaring it — the extension build fails with Cannot find module 'esbuild'. Added esbuild as an explicit extension devDependency. That dependency is now load-bearing for the build; it can come back out if the plugin ever declares it properly.

The MV3 invariant was re-checked by hand across the vite bump: dist/background.js is still a single 88.52 kB bundle with no dynamic import().

Two notes for #911's author

  1. #911's description contradicts its diff. The "Known follow-up, deliberately not fixed here" section says the match code was left alone; the diff adds match_code_from_digest and rewires parse_task_consent_request to it. That section is the one a reviewer needs in order to realise this repo has to move.
  2. #911 pins trust-tasks-rs 0.4.0, but 0.4.1 and 0.5.0 have since shipped. I checked — they add the ceremony envelope member and the /ceremony/ namespace, not a further digest change. This PR is unaffected either way.

Pre-merge checklist (vti-stack-development-guide §9)

- [x] No new reqwest::Client::new() / bare fetch(); all clients have finite timeouts (R1.2) — no network code touched
- [x] No lock held across a network await (R1.3) — n/a
- [x] No local state committed before its remote effect, or the flow is resumable with an idempotency key (R2.1) — n/a, pure decode + render
- [x] Every retry is bounded + backed off; non-idempotent ops are not blind-retried (R1.4) — n/a
- [x] Accept/poll/listen loops survive transient errors (R1.5) — n/a
- [ ] Acks/deletes happen only after durable handoff (R1.6) — still violated repo-wide (see CLAUDE.md "Known open defect"); not touched or worsened here
- [x] New/changed wire types: camelCase, deny_unknown_fields where security-relevant, schema registered, all consumers (incl. JS) updated (R3.*) — `payloadDigest` encoding change coordinated with #911; both consumers updated, shared fixture asserted in both
- [x] Config absence = most restrictive; fail-closed if enforcement can't start (R5.*) — an undecodable digest blocks approval rather than degrading the check
- [x] Logs/status claim only what was verified; background-job failures are surfaced (R6.*) — the screen states plainly when it cannot read a digest instead of rendering a plausible code
- [x] "Process dies on the next line" answered for every mutation touched (R2.1) — no mutations
- [x] Deviations from this guide flagged explicitly with rule numbers — R1.6 above; R4.1 is the reason this is cross-linked to #911

…ts encoding

Trust Tasks 0.4 moved `payloadDigest` to the shared `DigestMultibase` type — a
multibase-encoded multihash — and landed it errata-style, **in place**, on
`task-consent/{request,decision,granted}/0.1`. The type URI did not move. No
version check anywhere can detect this; the encoding is the only signal.

The approver screen sliced the first six characters of that string as the code
the human matches across two devices. Under base58btc every SHA-256 digest
begins `zQm` — the multibase marker plus the `0x12 0x20` multihash prefix,
identical for every digest ever produced:

    zQmcdLJ…   zQmRTnb…   zQmb7oR…   zQmbu6r…      ← four different payloads

So half the code would have been a format marker. ~17.6 bits where the human
believes they are comparing ~35, and it still *looks* like six random
characters — which is what makes it dangerous rather than merely wasteful. An
attacker searching offline for a payload that renders the same code would face
~195k candidates instead of ~60 billion.

This is the one check on that screen that a hostile device cannot defeat. Every
other check assumes the device is honest; only a comparison the human performs
across two independent screens moves the check somewhere the device cannot
reach. Quietly narrowing it to three characters would have left the ceremony
looking intact.

`matchCodeFromDigest` decodes the multibase, strips the multihash prefix and
hex-renders the leading bytes. Because the digest is still SHA-256 this
reproduces exactly the six characters this surface showed when the wire carried
bare hex, so the migration is invisible to the human and the two repositories
can cut over without stranding whatever the operator already has in front of
them. It agrees character-for-character with the VTA's
`vta_mobile_core::consent::match_code_from_digest`; the fixture
`zQmSK9pGKFnmc77…` → `3b0c7f` is asserted in both, and is the contract between
them.

A digest that will not decode now yields no code at all: the request renders an
explicit "this wallet cannot read it, do not approve", the type-to-confirm input
does not appear, and approval is blocked. The comparison *is* the control, so
with nothing to compare there is nothing to downgrade to. A stale hex digest
from a version-skewed agent fails here rather than at the executor, after the
human has already agreed.

The case-insensitive compare stays and is now correct rather than accidentally
so — the derived code is hex. It forgives the keyboard without widening the
match, which lowercasing base58 would have done.

Also decodes the other bases the framework's `^[zumbfF]` pattern admits, so a
conforming digest from a non-VTA executor is not refused, and reuses the
base58btc decoder already in `trust-tasks/canonical.ts` rather than adding a
second one. An unrecognised prefix is refused rather than guessed at — not
inferring the base from context is the entire point of multibase.

Wire-compatible in the other direction: `payloadDigest` is still echoed verbatim
into the decision, which the VTA's own round-trip test requires.

Dependencies updated in the same pass (everything in range; `npm outdated` is
now empty). vite 8.2 dropped esbuild and `vite-plugin-top-level-await@1.6.0`
requires it without declaring it, so the extension build died with
`Cannot find module 'esbuild'` — added as an explicit devDependency. The MV3
invariant was re-checked by hand across that bump: `dist/background.js` is still
a single bundle with no dynamic `import()`.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
@stormer78
stormer78 force-pushed the feat/digest-multibase branch from 0e55c6a to 6452b2c Compare August 9, 2026 20:51
@stormer78
stormer78 merged commit 6b02ff1 into main Aug 9, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/digest-multibase branch August 9, 2026 20:52
stormer78 added a commit that referenced this pull request Aug 9, 2026
Cuts the three changes sitting under `[Unreleased]` since 0.2.0: the
`digestMultibase` decoding and byte-derived approver match code (#113), the
canonical `auth/authenticate/0.1` type for VTA DIDComm auth, and the
consent-gated task detection that keys on `details.reason` rather than a
top-level code that never matched.

Minor rather than patch: `trust-tasks/digest.ts` adds public API
(`matchCodeFromDigest`, `decodeDigestMultibase`, `decodeMultibase`,
`MATCH_CODE_LEN`, `DigestMultibaseError`). Nothing in the TypeScript surface
was removed or narrowed, so no BREAKING section — but the release is not
drop-in, and the changelog says so under Migration rather than leaving a
consumer to find out from a mismatched code on an approval screen.

The reason it is not drop-in is that `payloadDigest` changed encoding with no
type-URI version to signal it — Trust Tasks 0.4 re-pinned the task-consent
specifications errata-style, in place. So this release pairs with
verifiable-trust-infrastructure#911 and the two have to move together. Unlike
the 0.1.3 authcrypt change there is deliberately no dual-accept fallback to
stage behind: the digest is what the approver signs, and accepting both
encodings would mean accepting two different digests for one payload — the
exact substitution the digest exists to prevent.

Both skew directions fail closed, which is what bounds this to an upgrade
inconvenience rather than a security event. A 0.3.0 wallet against a pre-0.4
VTA refuses the bare-hex digest and blocks approval with an explicit message. A
0.2.0 wallet against a 0.4 VTA displays `zQmSK9…` where the requesting screen
displays `3b0c7f`, so destructive approvals become impossible while
non-destructive ones still complete. Nothing is silently mis-approved either
way. Installed extensions need a rebuild — there is no store auto-update path
in this repository.

Dependents moved to `^0.3.0` in the same commit so the workspace keeps
resolving to the local package rather than the published 0.2.0.

Not published here. `npm publish` remains a manual step after this merges.

Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
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