Skip to content

feat(varsig): put the signing context in the payload, not the challenge - #37

Merged
NiKrause merged 3 commits into
mainfrom
feat/varsig-spec-challenge
Aug 15, 2026
Merged

feat(varsig): put the signing context in the payload, not the challenge#37
NiKrause merged 3 commits into
mainfrom
feat/varsig-spec-challenge

Conversation

@NiKrause

@NiKrause NiKrause commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Breaking: this changes the bytes that get signed. Entries written by earlier versions no longer verify. It also raises iso-webauthn-varsig to ^0.3.0, which itself changed the Ed25519 curve field.

What was wrong

The challenge was SHA-256(label ‖ payload) — a domain label mixed into the hash preimage. That separated the three signing contexts, but it meant the challenge was no longer the hash of the payload.

The varsig WebAuthn draft has a verifier re-hash the payload and compare it against the challenge (step 5 of its procedure). Nothing outside this package could ever reproduce our value. Measured against the draft:

here   base64url( SHA-256( "orbitdb-entry:" ‖ payload ) )   32 bytes
draft  base64url( 0x12 0x20 ‖ SHA-256( payload ) )          34 bytes

Two differences: no multihash prefix, and a different preimage.

Why the label is not simply a mistake

The same credential signs an identity, a public key and an oplog entry (domain.js). A signature over one must not be reusable as a signature over another — a real property the draft does not provide.

It cannot move into the varsig header either: the WebAuthn signature covers only authenticatorData ‖ SHA-256(clientDataJSON), so anything carried beside the challenge is unauthenticated and forgeable.

So the context moves into the signed bytes. bindContext() frames it length-prefixed ahead of the payload; buildChallengeBytes() is now a plain SHA-256 multihash of those bytes.

The length prefix matters: without it, context 'a' with payload 'bc' and context 'ab' with payload 'c' are identical bytes, and the separation could be sidestepped by choosing a payload that absorbs the label.

identity.js is untouched — the three labels flow through as before, they just enter the calculation elsewhere.

Verification

45 node tests and the varsig e2e pass, against the published iso-webauthn-varsig@0.3.0. Five new cases pin the properties that are easy to lose: multihash shape, step-5 reproducibility, context separation, frame unambiguity, and that the payload stays recoverable.

The judgement call

This is a bet on a draft. ChainAgnostic/varsig#11 has been open since 2023 and webauthn-varsig-header is still TODO there, so the header codepoint we emit (0x300001) remains a private-use value of our own choosing. If that discussion lands differently, this needs recutting.

Merging is reasonable because nothing is in production and every consumer is ours — but it is a decision, not a routine bump.

The challenge was SHA-256(label ‖ payload), which separated the three
signing contexts but meant it was no longer the hash of the payload. The
varsig WebAuthn draft has a verifier re-hash the payload and compare
against the challenge (step 5), so nothing outside this package could
ever reproduce it. Measured against the draft: no multihash prefix, and
a different hash preimage.

The label is not a mistake, though. The same credential signs an
identity, a public key and an oplog entry, and a signature over one must
not be reusable as a signature over another. That separation cannot move
into the varsig header either: the WebAuthn signature only covers
authenticatorData ‖ SHA-256(clientDataJSON), so anything carried beside
the challenge is unauthenticated.

So the context moves into the bytes that are signed. bindContext() frames
it length-prefixed ahead of the payload, and buildChallengeBytes() is now
a plain SHA-256 multihash of those bytes. Both properties hold: the three
contexts still produce different challenges, and a spec verifier can
reproduce the digest.

The length prefix matters — without it, context 'a' with payload 'bc' and
context 'ab' with payload 'c' are the same bytes, and the separation
could be sidestepped by choosing a payload that absorbs the label.

identity.js is untouched; the three labels flow through as before, they
just enter the calculation somewhere else.

BREAKING: this changes the signed bytes, so entries written by earlier
versions no longer verify. It belongs in the same release as the
CURVE_ED25519 correction rather than shipping on its own, and it is a bet
on a draft — ChainAgnostic/varsig#11 is still open with
`webauthn-varsig-header = TODO`. Kept off main until that settles.

Tests: 5 new cases pinning the multihash shape, step-5 reproducibility,
context separation and frame unambiguity. 45 node tests pass; varsig e2e,
focused and integration unchanged.
0.3.0 corrects the Ed25519 curve code, so `^0.2.0` no longer covers it.

Verified before publishing by installing the 0.3.0 candidate from a local
tarball: 45 node tests and the varsig e2e pass against it together with
the challenge change on this branch.

The lockfile is deliberately left at its previous state — 0.3.0 is not on
npm yet, so `pnpm install` has to run once it is.
Completes fa24234, which raised the range but left the lockfile alone
because 0.3.0 was not on npm yet. Verified against the published tarball:
45 node tests and the varsig e2e pass.
@NiKrause NiKrause changed the title Feat/varsig spec challenge feat(varsig): put the signing context in the payload, not the challenge Aug 15, 2026
@NiKrause
NiKrause merged commit 53b7320 into main Aug 15, 2026
6 checks passed
@NiKrause
NiKrause deleted the feat/varsig-spec-challenge branch August 15, 2026 06:47
@NiKrause NiKrause mentioned this pull request Aug 15, 2026
NiKrause added a commit that referenced this pull request Aug 15, 2026
* chore(release): 0.5.0

A major-breaking release: the bytes a credential signs change, so this is
not an upgrade peers can do one at a time.

Two breaks, one of them narrower than it looks. The varsig signing context
moves out of the challenge and into the signed payload (#37), so the
challenge is once more a plain SHA-256 multihash of what was signed and a
spec verifier can reproduce it. And iso-webauthn-varsig 0.3.0 corrects the
Ed25519 curve code from 0xed01 — the varint encoding of ed25519-pub, not
the code itself — to 0xed. That second one leaves P-256 bytes untouched.

Consequence for consumers: entries and identities written under 0.4.x no
longer verify, and a relay that verifies identities has to move in the
same step as the peers it serves. The CHANGELOG spells out the migration.

Kept honest about the risk: this follows a draft that is still open
(ChainAgnostic/varsig#11 has webauthn-varsig-header = TODO), so the wire
format may move again.

45 node tests pass against the bumped version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: satisfy prettier in the 0.5.0 changelog entry

Emphasis has to be _underscores_, not *asterisks*. `format:check` is a
gate in Run Tests, so this failed the whole job — the tests themselves
never ran.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.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