You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
docs(verifier): how to run a nym_auth verifier, and what each refusal means - #832
Stacked on #831 (on #830, on #829). Targets main like the rest of the stack, so the diff shows the commits below it — review only the last commit. Merge after #831.
This is the docs half of step 4. The operator-page half is on hold pending leifj's call on what it should show; nothing is built for it here.
Why
There was no verifier-side document at all. docs/issuer/SEED_COMMITMENTS.md covers the issuing half and had no twin, so an operator turning this on had the configuration reference and nothing explaining why any of it matters.
It is written around the failure mode rather than the feature, because this feature has an unusually nasty one: a pseudonym is only meaningful if every verifier computes it the same way, so a verifier that gets an input wrong produces a value that matches nothing — and a value that matches nothing reads as this is not the person you are looking for. The failure is a wrong answer about a person, not an error anyone sees. The document says that up front and then explains each refusal in those terms, with a table mapping the message an operator will actually see to its cause.
Covered
the zknative build, without which nothing verifies at all
verifier_id: the bare value, and the two markers that look like prefixes and are not the same kind of thing
the circuit allowlist, why nothing outside it loads, and why ssc and psc are not interchangeable
preferred_vp_formats.zkp_req, where offering nothing refuses everything
trust.pdp_url, and why allow-all is not an option here
where the issuer key comes from, and why a holder-supplied chain is not the hole it looks like
the two handovers, and why the channel is not a configuration choice
the direct_post.jwt requirement over redirects
reading a refusal
what this deliberately does not do
Checked, not written from memory
Every quoted message, YAML key and digest was verified against the code. Two claims did not survive that and were corrected before committing:
the trust role is credential-issuer, not mdoc-issuer-auth — I had carried the latter over from the design brief's wording without checking the constant;
For any identified claim in any credential type, a configuration may now
declare that the claim carries a commitment over a per-subject seed rather
than a value from a data source. The issuer holds the seed; the credential
carries commit(seed) and never the seed.
The point of holding it is reproducing it: a pseudonym a Relying Party
presents can be mapped back to a person, which is what known-offender
matching and account recovery in the EUDI nym_auth rulebook are built on.
nym_auth is the first consumer, not the feature. The claim path, the
credential it belongs to, the digest and the seed length are configuration,
and pkg/credential/seed names no credential type.
**What the issuer does not do**, each refused on purpose rather than left
undone:
- It does not deliver the seed. Only the commitment is written, as a claim.
A derived seed is recomputable whenever it is wanted, so there is no
moment at which one must be handed over to avoid losing it.
- It does not keep a commitment from being disclosed. That is a wallet's
job against a rulebook; a control here could not stop a verifier that
asks, only create the belief that something was.
- It does not check a consumer's byte-level issuance profile.
**The derivation.**
seed = HKDF-SHA256(secret, info = lp("vc/seed/v1") || lp(seed_bytes)
|| lp(context) || lp(subject))
Every field is length-prefixed rather than joined, so no two can be slid
into one another - with a separator, context "a" + subject "b.c" and "a.b"
+ "c" would be one derivation and two people would share a seed. The seed
length is in there too, which it need not be: HKDF expand is a stream, so
a 32-byte seed would otherwise literally begin with the 16-byte seed for
the same subject.
The secret is NOT in the info. It is the HKDF key, it never changes, and
naming it would invite rotating it - which would repseudonymise everyone
ever issued to. There is deliberately no key identifier and no rotation
mechanism, and the documentation says in those words that losing it is
unrecoverable.
**Fail closed, all of it.** A derivation secret that is absent, too short,
or configured twice over; a seed model or a holder source that needs the
store this issuer has not got; a claim naming a seed source that does not
exist, or no subject path, or an unregistered digest - every one of them
refuses at startup rather than at first issuance, when someone is waiting.
At issuance: a subject identifier that is absent, empty or not a string,
and a commitment arriving from upstream in the document data, which would
produce a credential that looks remappable and is not.
**Also here:** pkg/nymauth implements the rulebook's pseudonym-v1-sha256
and its four configurations, and developer_tools/scripts/seed_recover
reproduces a subject's seed, commitment and pseudonym from the issuer's
own configuration, exiting 2 on a mismatch so it can drive a loop over
candidates. The rulebook publishes a vector for the seed/commitment pair
and none for a pseudonym, so that construction is from the specification
text and is marked as unchecked against a reference output.
Refs SUNET#791.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Five findings, all of them real.
**Two signing paths were skipped.** MakeVC20 and MakeJWP signed
req.DocumentData unchanged while startup accepted a commitments block for
any format - so a W3C or BBS scope could configure a commitment claim,
start cleanly and issue credentials without it. Silently, which is the
whole failure class this feature is supposed to close.
Both inject now. A new signing path added later would make the same
omission and no test written against today's handlers would notice, so
TestEverySigningPathInjectsCommitments walks the package AST for methods
on *Client named Make* whose request carries DocumentData, and fails if
one does not call injectCommitments. A position guard, because what it
catches is an absence.
**A literal dotted key shadowed the nested path.** lookupPath tried the
exact key before descending, in every format. In a nested document
"identity.id" and {"identity":{"id":...}} can both be present and carry
different people, so the seed could be derived from the wrong subject with
both lookups succeeding and nothing reporting anything. Flat and nested
are exclusive now, decided by the format, not ordered.
**The derivation was not pinned to anything.** Every test compared Seed
against Seed, which proves consistency with itself: changing the label,
field order, prefix width, hash or HKDF parameters would have left them
all passing while giving every subject a new seed, with no copy of the old
ones anywhere because nothing is stored. There are golden vectors now,
computed from the doc comment's construction by a separate implementation
rather than captured from this one.
**pkg/nymauth accepted any seed length.** The rulebook fixes both
secret_seed and private_seed at 16 bytes; anything else hashes a
concatenation shifted against what the circuit computes, and the output is
32 bytes either way so nothing downstream could tell. Reachable through a
generic seed source configured with a different seed_bytes - the cost of
the primitive not being nym_auth-specific, and now refused.
**The seed-model constants stole BBSConfig's doc comment**, dropping the
software-key/HSM explanation from the generated configuration reference.
Moved above it; a test-time assertion in the same change checks that
BBSConfig, SeedSource and CommitmentClaim each still adjoin their own
comment. docs/CONFIGURATION.md regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Two more findings, both right.
**The redaction did not hold.** String and GoString are consulted only for
the string-capable verbs; a numeric one falls back to reflection, walks
into the struct and prints the secret as decimal bytes. Deriver implements
fmt.Formatter now, so the rendering is the same under every verb -
including ones where it reads oddly, because a redaction that holds for
the verbs someone thought of is not a redaction.
The test enumerates fmt's verb table instead of a hand-picked list, and
each verb is also checked through an enclosing struct, which is how this
would actually reach a log. Removing Formatter makes it report SIX leaking
verbs, not one: %d, %e, %f, %g, %O and %U. The review named %d; the
hand-picked list in the previous commit would have caught none of them.
**A path conflict deleted the claim in the way.** Injecting identity.ssc
into {"identity": "existing"} type-asserted the scalar to a map, failed,
and created a fresh map over it - so the upstream claim was gone from the
document before signing and the credential was issued missing an attribute
nobody asked to remove. Silently, because the injection succeeded.
The nested case delegates to credential.SetNestedValue, which already
refuses this and already has tests. Reimplementing the traversal is what
produced the bug; the error propagates and refuses the issuance.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
…ntry
**A copied Deriver still printed the secret.** Format was on a pointer
receiver, so fmt.Printf("%v", *d) and any struct holding a Deriver by
value fell back to reflection. Value receivers now - a pointer's method
set includes value-receiver methods, not the reverse.
Covering copied values then exposed one more: %p on a value. fmt resolves
%T and %p BEFORE it consults Formatter, and the bad-verb path it lands on
sets its erroring flag, which disables String, GoString and Format
outright - so no method can intercept it. go vet flags %p on a non-pointer,
but "vet would have caught it" is not the same as "the key cannot be
printed". The secret lives behind a pointer field now, which renders as an
address at that depth, so there is nothing to read either way.
**mdoc injected after the status list entry was allocated.** Injection can
refuse a document - no subject identifier, an upstream commitment, a path
conflict - and the entry is allocated before signing and never handed
back, so every such refusal burned a revocation entry marked VALID and
belonging to no credential. The other three signing paths already injected
before their allocation; this one did not, which is my inconsistency
rather than anything about mdoc.
The AST guard now pins the ORDER as well as the presence, so the next
signing path cannot get this wrong either.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
**seed_recover reported a match for every candidate.** -expect without
-pseudonym-config returned before computing or comparing anything, and
exited 0. The documented use is a loop over candidate subjects driven by
the exit code, so an operator remapping a pseudonym would have concluded
the first person they tried was the one behind it. The combination is
refused now.
**An mdoc commitment target the schema does not declare was dropped.**
addElements emits the elements the schema declares and ignores anything
else in the document, so injection succeeded and the credential was issued
with no commitment in it, reporting nothing. Refused in two places,
because neither alone is enough: the issuer checks at issuance, since it
receives schemas inline per request and has none at startup; the apigw
checks at startup, since it does have them and that is where an operator
should find out. The namespace-qualified spelling is accepted, because
that is how the claim is named everywhere else.
**One commitment could derive its seed from another.** The injection loop
read subject paths from the same map it was writing into, so a subject
naming another commitment's target found the value just injected - a seed
derived from a commitment, identifying nobody. Sorting the paths made that
deterministic rather than correct, which is worse, because it looks fixed.
Every subject is resolved from the document as received before anything is
written.
Each of the three fails silently rather than loudly, which is what they
have in common and why they are worth the churn: a wrong answer in a
remapping loop, a credential missing the claim it was issued for, and a
seed derived from the wrong thing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
**A namespace-qualified target was written verbatim.** I added that
leniency in the previous commit - accept the qualified spelling, since
that is how the claim is named everywhere else - and accepted it without
normalising it. An mdoc document is flat and keyed by BARE element
identifier, so the value was written at a key addElements never reads and
dropped. Worse, the pre-existing-value check looked at the qualified path
too, so an upstream bare "ssc" went unnoticed and was what got signed: a
commitment this issuer did not compute, in a credential that looks
remappable and is not. Resolved to the bare element id now, for the
lookup, the write and the startup check alike.
**encoding: hex against a bstr target signed 48 bytes of nothing.** A bstr
element arrives as JSON and mdoc decodes it as standard base64 - and a
64-character hex commitment is itself valid base64, so it does not fail.
It decodes to 48 bytes and gets signed, and a 48-byte bstr is perfectly
well-formed, so nothing downstream can tell. Refused at startup and at
issuance.
**Injection rewrote numbers it never touched.** Decoding into
map[string]any turns every JSON number into float64, so re-encoding
changed any integer past 2^53: 9007199254740993 was signed as ...992.
MakeJWP forwarded the document untouched before this feature existed, so
this was a silent data change for every number in every document passing
through. json.Decoder with UseNumber.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Four findings, three of them regressions from my own last two commits. The
pattern is the point: I had two implementations of the same resolution -
one in the issuer for issuance, one in pkg/configuration for startup - and
every local fix to one of them drifted them further apart. So this is a
pass over the resolution rather than another patch.
CredentialMetadata.ResolveCommitmentTargets is now the only place that
decides where a commitment goes and how it is encoded. Both callers use
it. The schema is a parameter, since the apigw loads one at startup and
the issuer receives one per request - and because taking it from the
struct meant copying a struct that carries a mutex, which go vet was
right about.
What it fixes:
**Two configured paths could alias one element.** "ssc" and
"<namespace>.ssc" both resolve to "ssc"; neither is written when the
existing-value check runs, so both passed it and the second write silently
replaced the first commitment. Refused, naming both paths.
**A nil claim panicked at startup.** The check ran before the struct
validation that would have rejected it, so "commitments: {ssc: null}" took
the service down with a stack trace instead of a message.
**Resolution depended on map order.** A dotted path can be a literal
element identifier in one namespace AND a qualified spelling of a
different element in another. One pass over the map returned whichever
came up first, so startup could validate one target and issuance write
another. Exact matches are now resolved in a full pass before any
qualified matching, and a path qualified in two namespaces is refused
rather than guessed.
**Trailing bytes were accepted.** Last commit replaced json.Unmarshal with
a Decoder to get UseNumber, which is strictly weaker: a Decoder stops at
the end of the first value, so `{"a":1} garbage` decoded and re-marshalled
as valid JSON. MakeVC20 had full-input validation before this feature
existed and the JWP validator still assumes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
…ents
**The trailing-bytes check was unsound.** decoder.Buffered() exposes only
what the decoder happened to prefetch, not what is left in the reader - so
with enough whitespace between the object and the garbage the buffer holds
nothing but whitespace and the check passes on exactly the input it was
written to reject. A second Decode required to return io.EOF instead.
The test case for it is the one that distinguishes them: with Buffered()
the bare word, the second object and the stray brace are all still caught,
which is why the original looked right.
**A doc comment was displaced again.** Inserting checkCommitmentTargets
directly above checkCredentialMetadataEntries left that function's
explanation attached to mine. Same mistake as the BBSConfig one earlier in
this PR, which I "fixed" with an assertion naming three specific types -
a guard that enumerates cases cannot catch the case it did not enumerate.
So there is a real one now, in both packages where I made the mistake: it
derives the set of declarations from the AST and requires each doc comment
to begin with the name of the thing below it. It catches the reinsertion,
and it would have caught the first instance.
The failure deserves the guard rather than more care: the inserted code
compiles, every test passes, and the diff shows the displaced lines as
unchanged context. What it breaks is generated documentation describing
the wrong thing, which is to say it usually does not surface at all.
One pre-existing violation is exempted with its reason - pkg/model's
OIDCOP carries a comment naming the type's former name, which is on main
and not this PR's to sweep in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
`go build ./developer_tools/scripts/seed_recover/` writes the binary to
the working directory, not to bin/, and it was committed three commits ago
by a blanket `git add -A`. The Makefile target builds to bin/, which is
ignored; this adds the stray path as a backstop.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
**The guard was wrong, and I said otherwise.** I claimed it "would have
caught the first instance" - the const block inserted under BBSConfig's
explanation. It would not have: it skipped parenthesised declarations
entirely, so the group went unchecked, and the type below it was by then
undocumented so it went unchecked too. Nothing failed. The claim is in the
previous commit message and in a review reply, and it was false.
Rewritten around NAMES rather than positions: every declaration name in
the package is collected, and any doc comment - group or not - that begins
with a name belonging to a different declaration is a displaced comment.
Verified by reproducing the original mistake rather than by reasoning
about it: putting the const block back under BBSConfig's comment now fails
with that comment quoted back.
**Nested parent and child targets could not both be written.** In a nested
document "a" and "a.b" are mutually exclusive - the outer is written as a
string, so writing the inner has to descend through it. Startup accepted
the configuration and every issuance failed with a path conflict, which is
a deterministic configuration error an operator should hear about at
startup. Not a relation in a flat mdoc document, where a dotted element
identifier is one key.
**Only bstr and tstr elements may carry a commitment.** It is injected as
a JSON string. int and uint fail conversion after a status list entry has
been allocated and never handed back; bool, array and map do not fail at
all - convertElementValue passes an unrecognised value_type through, so
the commitment is signed as a CBOR text string where the schema promises
something else, and only a verifier would ever find out.
**The secret file format was documented wrong.** It said "at least 32
bytes" where seedSecret base64url-decodes it first, so an operator
following the generated reference supplies 32 raw bytes and gets a decode
error or 24 bytes. Now says base64url, decoded value at least 32, with a
command that produces one. docs/CONFIGURATION.md regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
…ier ids
All three of this round's findings are the same shape: a value that is
written correctly and then replaced, or computed correctly from an input
that could never have been real. Nothing errors in any of them.
**The SD-JWT builder overwrites its envelope claims.**
BuildCredentialWithSigner assigns iat, nbf, exp, iss, jti, vct,
vct#integrity, cnf and status unconditionally, AFTER the document this
feature injects into has been parsed. A commitment configured at one of
them was injected perfectly well and then replaced, so the credential
carried the envelope value instead of commit(seed) - both writes
succeeded, so nothing reported anything.
The root segment is what is checked, not the whole path: status is
assigned as a whole object, so status.value is destroyed just as
completely.
**MakeVC20 fills credentialSubject.id from the request.** Same shape, one
claim, and also after injection.
Both lists are taken from the signing code rather than from a
specification, since it is the code that does the overwriting.
**nymauth accepted any verifier_id.** The rulebook defines exactly
semantic_id:<value> and x509_san_dns:<value>. Hashing "rp.example"
succeeds and returns a 32-byte value indistinguishable from a correct
pseudonym, so a remapping loop comparing against what a Verifier actually
reported never matches - and the operator concludes the subject is not the
one they are looking for. A wrong answer in the workflow this exists for,
which is the third one of those in this PR.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
SonarCloud's quality gate was failing on duplication - 7.2% against a 3%
threshold - and all of it was mine: 125 identical lines, because I put the
same guard in two packages rather than extracting it. I had noticed CI was
green and not that this check was not.
It is a package now, and the two tests are six lines each. The extraction
also gives it its own tests, which it should have had from the start: a
guard with no test of its own is a guard nobody has checked, and this one
was wrong the first time I wrote it.
Those tests cover the case that motivated it - a parenthesised group
inserted between a type and its explanation - plus displacement between
two single declarations, a group comment that merely names nothing, the
exemption path, and that test files are skipped. The end-to-end check
still holds: putting the seed-model const block back under BBSConfig's
comment fails pkg/model's guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
MakeCredential turns a claim into a disclosure only when the VCTM lists
its path with sd "always". Anything else is signed in the CLEAR - so a
commitment at a path the type metadata does not declare, or declares as
"allowed", was not missing from the credential. It was issued, permanently,
in a form the holder cannot withhold.
That is the one outcome this feature must never produce. The commitment is
a stable identifier for its holder - the same value in every credential
ever issued to them - and the wallet's ability to keep it back is the
entire protection. Issuing it undisclosable hands it to every verifier
unconditionally, and nothing anywhere reports that it happened.
Checked in the shared resolver, so startup checks it against the
configured VCTM and issuance checks it against the one the request
carries. The issuer parses that VCTM before injecting rather than
trusting configuration, because it is the document actually being signed.
That parse now runs before the builder's own, so it reports an invalid
VCTM with the wording the builder used - an existing test pins that
contract and it should keep holding, rather than the test being updated to
match my message.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Both findings narrow the disclosure check added in the previous commit,
and both let the same thing through: a commitment signed in the clear,
which the holder cannot withhold.
**The checks only recognised one SD-JWT spelling.** "vc+sd-jwt" is the
legacy identifier this repo still issues and "" is an unset Format; both
reach the same signing path. pkg/openid4vp's DCQL validation already lists
all three, with a comment saying why - "every place that decides what a
format MEANS has to agree with every place that decides whether it is
CONSTRAINED" - and I had just done the thing it warns about.
The review named the disclosure check. The reserved-claim check had the
same hole and is now fixed with it: reservedTargets was keyed by format
identifier, so a legacy-spelled scope could also put a commitment at cnf
or status and have it overwritten by the builder. Both now go through one
predicate, and the reserved lists are keyed by format FAMILY rather than
by one of a format's spellings.
openid4vp.IsSDJWTFormatIdentifier is deliberately not used: it has the
same narrow definition, and it has no production callers, so widening it
would mean editing a test that pins it in a file this change has no other
reason to touch. Noted rather than swept in.
**VCTM paths were flattened before comparison.** path ["a.b"] is one
top-level key containing a dot; path ["a","b"] is nested. A configured
"a.b" is always injected nested, so joining the VCTM segments made a
literal top-level declaration satisfy a check for a nested claim -
MakeCredential then never turns the nested value into a disclosure, and
the commitment stays in the clear. Compared segment by segment now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
…builder
The review named _sd_alg. There were two: _sd is written at the payload
root as well, since a top-level claim's disclosure digest goes into
whatever object its parent is, and for a top-level claim that is the root.
Both are now reserved, and reserved beats declared - a VCTM listing either
with sd "always" does not help, because the builder assigns them
regardless. There is a test pinning that ordering.
More to the point, the list is no longer something I maintain by reading.
I built it by grepping for one variable name, which is how it came to be
missing a claim assigned to a different one and another assigned deeper
in. TestReservedSDJWTClaimsCoverTheBuilder parses pkg/sdjwtvc/methods.go
and fails if the builder assigns a root claim the list does not contain -
so the next claim added there fails this test rather than producing
credentials with a commitment nobody can read.
If the builder moves, the test fails and says where to point it. That is
the intended behaviour: a list derived from code that is no longer there
is a list that has quietly stopped being true.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
… for
For a W3C credential the proof covers the RDF canonical form, where a
property the JSON-LD contexts do not define expands to a relative
predicate and is DROPPED - and RDFCredential.ToCompactJSON then returns the
original JSON verbatim. So a commitment at an undefined term is visible in
the issued credential and NOT covered by the proof over it: alterable
without invalidating the signature.
That is worse than the format not being supported, so it is not supported.
Refused at startup, naming the reason.
Supporting it properly means resolving commitment paths against the
pinned contexts and refusing undefined or non-absolute predicates. That is
real work and belongs with the JSON-LD context handling, not bolted onto
this.
The honest history: mso_mdoc and SD-JWT VC are the formats the design
covers and the ones whose signing layers this code has a complete account
of. W3C and JWP injection were added reactively in the first review round,
to answer a finding about coverage, without that account - and this is
what was behind the gap. The first round's finding offered "reject
unsupported formats during startup validation" as the alternative, which
is what this now does.
The blanket refusal subsumes the reserved subject identifier those formats
carried, so that entry is gone and the reserved lists have one family left.
My own doc guard caught the comment displacement that caused, which is the
first time one of these guards has fired on live code rather than on a
deliberate probe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
First of two for SUNET#794. The wire model and its conformance tests; no
verifier wiring and no configuration, which is PR 2.
A circuit_zk query discloses nothing: every value a Verifier wants is the
OUTPUT of a function it asks for in `req`, computed inside the circuit and
returned under a handle. That makes it unlike mso_mdoc_zk, which is a ZK
presentation of an mdoc and still discloses attributes.
**The shape is settled by the shared vectors, not by prose.** The
rulebook's normative text and its WIP example disagree - `req` with
id/return/params against `claims._req` with fn_id/id - so the vendored
planner vectors from siros-dc-matcher decide it. This emits the normative
form; the WIP form is reachable but not modelled.
**Nothing here validates.** The builder is a serialiser: no coercion, no
dropping empties, no repair. Two reasons, and either alone would be
enough. The verifier is the tool wallets are tested WITH, so a builder
that fixed a bad request could not produce the bad requests a negative
test needs. And the rulebook, OpenID4VP and the DC API are all moving
while several wallet generations sit in the field, so a request shape this
build has never been taught still has to go out.
Hence extension at every level: `Extra` on the credential query, on a req
item and on the circuit_zk client-metadata entry, flattened rather than
nested; and `Raw` on the credential query for the case Extra cannot cover,
where a member already modelled changes TYPE. The rulebook's WIP example
does exactly that to `claims`, and a later generation could do it to `req`
or `meta`. One escape hatch covers all of them; a per-member override
would be a field this struct grows every time a specification moves.
What the tests pin:
- Every one of the 51 shared vectors can be emitted byte for byte. 30
through the typed model, 20 through Raw, and a separate guard asserts
that every shape a wallet must ACCEPT goes through the typed model -
with one recorded exception, the WIP envelope - so the first test cannot
pass by everything quietly falling through to Raw.
- Adding MarshalJSON to CredentialQuery and VPFormatsSupported changes how
every format is encoded, not just this one. With no extension set the
output is byte-IDENTICAL to the struct encoding it replaced, compared
against a method-less type alias. These bytes are signed, so equivalent
is not good enough.
- Requests violating rulebook MUSTs - le before ge, two pseudonym items, a
duplicate handle, an unknown function, an over-long verifier_context -
go out exactly as configured.
- What a Go struct cannot say is named rather than left to be discovered:
absent and empty are the same for a string field, so four shapes in the
shared set reach the wire only through Raw.
The vectors are vendored rather than fetched, so the suite needs no
network. A snapshot goes stale silently, so refreshing is a command -
`make vendor-circuit-zk-vectors` - and SOURCE records the commit it came
from.
Refs SUNET#794.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Three findings, two of them mistakes I made and fixed in SUNET#793 and then
made again here.
**copyDCQL dropped every new field.** It reconstructs each credential query
member by member, so Req, Extra and Raw simply vanished on
TemplateDCQLQuery - the verifier's main template path. Silently: the
result is still a valid query, just not the one that was configured, and a
circuit_zk request that lost its functions asks the wallet for nothing.
The fields are copied now, but the fix that matters is the guard.
TestCopyDCQLCarriesEveryCredentialQueryField derives the field set from
the type: the fixture must leave no field zero, and the copy must equal
it. Adding a field fails it twice - once for not being covered, again if
the copier drops it. A list in a comment would have been correct until the
next field.
**Params lost precision.** Decoding into `any` makes every JSON number a
float64, so 9007199254740993 was re-emitted as ...992 - a parameter the
verifier never configured, inside a request a wallet is being tested
against. That is the same defect as the UseNumber one in SUNET#793, in a new
package, written by me after fixing it there. Every decode in this model
now goes through decodeExact, nested values included.
**The collision check only saw what omitempty left.** It compared Extra
against the ENCODED output, so a modelled member whose field happened to
be empty could be supplied through Extra after all - quietly, and only
sometimes, which is the worst shape a rule can have. It now checks against
every member the type declares, derived by reflection over the struct
tags rather than listed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
The circuit_zk client-metadata entry carries doc_example tags, so the
generated reference moved and config-docs failed. I added the tags and did
not run the generator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Extra was tagged yaml:"-", so an operator configuring
.verifier.preferred_vp_formats.circuit_zk had its members read and
silently dropped - an escape hatch that existed in the generated
documentation and not in the product. The envelope form the docs
explicitly advertise, fn_id and input, was exactly what could not be
expressed.
The review named CircuitZkFormat. openid4vp.DCQL is itself loaded from
YAML by pkg/configuration's presentation request templates, so
CredentialQuery and ReqItem had the same hole and would have produced the
same finding against PR 2. All three have MarshalYAML/UnmarshalYAML now,
mirroring ZKSystemTypeSpec, which solved this for its own Params in this
package.
A test asserts YAML and JSON agree about what is modelled and what is an
extension: the same request written on either side has to go out the same,
or a template behaves differently depending on which form it was authored
in.
**And the collision check I wrote for YAML had the bug that had just been
found in the JSON one.** It compared against the populated map, so a
modelled member whose field was empty could be supplied through Extra
after all - the same "quiet, and only sometimes" failure, reproduced
within the hour while fixing something else. My own test caught it. Both
sides now check declaredJSONKeys rather than inspecting output, and the
YAML test covers the empty-field cases specifically, since those are the
ones a populated-map check passes.
docs/CONFIGURATION.md regenerated.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Three findings on the YAML commit, all mine.
**Raw could not survive the HA cache.** A request object is JSON-encoded
into Mongo or Redis and read back by another node, so a raw query the
model cannot hold - object-valued claims, say - was written out fine and
then failed to load anywhere except the node that built it. An escape
hatch that only works on one replica is not one.
Decoding is total now: a shape the typed model cannot hold is kept
verbatim as Raw rather than refused. That also keeps this serialiser's own
promise - refusing to read back something it had just written would be a
repair by other means. Invalid JSON is still an error, since the fallback
keeps shapes, not rubbish.
**The YAML decode went through JSON, and should not have.** The round trip
worked, but only because of what the current encoding/json does with
yaml.v2's map[any]any: it coerces an integer key to a string and drops a
sequence key SILENTLY, and that behaviour has moved between releases.
Known members now decode into a methodless alias so yaml.v2 applies the
struct tags itself, and unknown members are normalised separately, with a
non-string key refused rather than coerced or dropped.
The review said this breaks any template with a nested mapping. It does
not, on this toolchain - I checked before changing anything, and ordinary
meta/req templates load - but the fix is right anyway, because relying on
that was not something I had decided to do.
**A wrong type was silently emptied.** The comma-ok assertions turned
`id: 7` into "". Through the alias, yaml.v2 coerces it to "7" exactly as
it does for every other configuration struct in this repository, and a
structural mismatch is an error. Coercion rather than refusal is
deliberate: diverging here would make this one type behave unlike the
whole configuration surface.
One test needed correcting rather than extending. The emission guard
counted a vector as "handled by the typed model" whenever decoding did not
error - which, once decoding became total, was always. It checks Raw is
nil now, so the distinction it exists to make still holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Found while wiring the next branch on top of this one, which is where it
would have been noticed in production too.
A raw query carries no Format - the bytes are the query, and nothing here
models them. But an empty Format means SD-JWT to every other reader in
this package, so `ValidateCredentialQuery` sent a raw query down the
SD-JWT branch and refused it with "vct_values is required". That check
stands on `UIInteraction`, which is the one door a browser-built query
arrives by, and on both VC 2.0 paths. An escape hatch that cannot be sent
is not one.
It now declines to judge a raw query. That is the honest answer rather
than a convenience: nothing in this package knows what these bytes mean,
so there is no rule to apply to them. It still checks they are JSON,
because they are emitted verbatim and bad bytes would otherwise surface
as an unencodable request object at send time, a long way from the
configuration that caused it.
Writing the test corrected my own account of the bug. Two of the three
shapes I first reached for - a query with no format, and one naming a
format this build refuses - do not fall back to raw at all; they are
modelled, so they decode typed and are judged exactly as before. The
hatch opens only for a shape the model cannot hold, which is a narrower
door than I had described, and the cases now pin shapes that actually go
through it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Two findings on the previous commit, both right, and both about what
making the decode total actually did.
**The stack routes by members raw had erased.** `UIInteraction` copies a
query's id into the authorization context's scopes and the response path
matches a presentation back to it, so a query that arrived raw and kept an
empty id was sent correctly and then matched nothing that came back - the
request works and the result vanishes, which is the worse half of the two.
Format is read the same way by every format decision downstream.
Both are now taken out of the raw bytes and kept in the struct, best
effort: a member that is absent or of a type this build does not expect is
left alone rather than coerced. Raw stays authoritative for the wire;
these are a read-only shadow of what is already in it.
**And it had become a way around the constraint rules.** Any query with
one wrong-typed member fell back to raw, and raw was then not judged at
all - so an SD-JWT query with no vct_values passed the ingress and would
be signed and served. That is an unconstrained request, which is precisely
what those rules exist to refuse; I had written a test asserting the new
behaviour, which documented the reversal rather than catching it.
A raw query is now judged by its own bytes: format and meta are re-read
from them and the normal per-format rule applies. The member that carries
the CONSTRAINT usually survives whatever made the query unmodellable - the
WIP rulebook shape only changes "claims" - so this is not a consolation
check, it is the same check. An unreadable meta yields no verdict, because
there is no constraint there to judge; an ABSENT meta is readable and zero,
so the unconstrained case is still caught.
The review suggested restricting the hatch to circuit_zk instead. That
would not fix this - a circuit_zk query can be unconstrained too - and it
would break the thing the hatch is for, since a later generation of any
format is exactly what cannot be named in advance. Judging by format,
whatever the format turns out to be, covers both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Both from the suppressed findings, both in code the reviews had not
flagged before, and both the same omission: an extension point that works
from JSON and not from the path an operator actually uses.
**preferred_vp_formats is loaded from YAML.** VPFormatsSupported.Extra is
tagged `yaml:"-"` and the type had only the JSON pair, so a format
identifier this build has never heard of was dropped silently at exactly
the point it would be configured. The promise held only for metadata this
code constructed itself, which is not where a format that postdates it
comes from. It has the flattening YAML pair now, like the other bags.
**And req params are an input to a proof.** The struct tag puts params in
a map[string]any, but yaml.v2 leaves every nested mapping inside it as
map[any]any, so params was the one open tree not getting the walk Extra
gets. A key with no JSON spelling is then coerced to its rendering, or -
for a sequence key - dropped with no error anywhere. A changed parameter
produces a different pseudonym, and that failure appears nowhere near the
configuration that caused it.
Worth being exact about what changed, because one of my tests was not. A
nested tree whose keys are all strings already encoded correctly through
encoding/json here; the walk changes the non-string-key cases, and makes
staying correct this code's decision rather than that package's. The test
I first wrote asserted the nested-tree case and passed with the fix
reverted. It is still there, renamed to say what it actually pins - that
the walk does not break a valid tree, which is the direction a rebuild
can break - and the behaviour change is pinned separately.
Also in that pair: a sequence key never reaches the walk, because yaml.v2
refuses it first. Asserted on the outcome rather than the wording, since
which layer catches it is not the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Third time on this branch that treating "raw" as "unjudgeable" opened a
door, and this one I had been told about in general terms already: a check
that cannot compare must refuse, not pass.
A raw query whose meta was a shape this build could not read returned
success. So `{"format":"vc+sd-jwt","meta":"not an object"}` reached the
signer with no vct_values - the exact case the format rules exist to
refuse, reached by making the member HARDER TO READ rather than by leaving
it out. A query that is not an object at all went the same way.
An unreadable meta is now judged as an EMPTY one. Downstream, the two are
the same thing: either way nothing has established what the request is
limited to.
Substituting the zero value rather than listing the formats that have
rules keeps the two in step by construction - a zero MetaQuery satisfies
no constraint rule in the switch and passes the permissive default exactly
where there is no rule to fail, so a format that grows one later is
covered with nothing here updated.
The error says what actually happened. "vct_values is required" would send
an operator looking for a member that may well be present, inside the meta
this could not read.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
The routing members are populated by UnmarshalJSON, so a query built in
code - CredentialQuery{Raw: ...}, which is how the escape hatch is meant
to be used directly - carried none of them. Validation then saw an empty
Format, which every reader in this package takes for SD-JWT, and refused a
raw circuit_zk query for a vct_values it was never going to have. On every
path that validates.
The bytes are the source of truth for id and format, so validation reads
them from there now and does not trust the struct's copies. Those copies
exist for the routing the rest of the stack does, which is a different
job; reading them back here was me using one as the other.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
The shadow started as "id and format", because those were the two members
I had a concrete report about. That was a list, and lists go stale on the
next member - which is this one.
A raw W3C query with valid meta.type_values passed validation from its own
bytes and was then MATCHED against an empty shadow Meta, so it rejected
every credential. Explicit multiple and
require_cryptographic_holder_binding settings reverted to their defaults
the same way. Raw is authoritative for the wire and nothing else; plenty
of this stack reads the struct, and it was reading values nobody chose.
Every json-tagged member is shadowed now, derived from the struct tags, so
a member added to CredentialQuery is covered by the commit that adds it.
Still best effort per member: one that is absent, or of a shape this build
cannot read, stays at its zero value rather than failing the whole decode -
a member this code has never seen is why these bytes are raw at all.
A query that is not a JSON object is treated as an unreadable meta rather
than a separate case. It carries no members, which is the strongest form
of "the constraint cannot be read", and it has to keep failing closed.
Also moved here from the branch above, where I had put it by mistake: the
vendoring target recorded the matcher's HEAD while copying from its
WORKING TREE, so a dirty or partly-untracked checkout produced a SOURCE
line naming a commit that cannot reproduce the vendored files. These
vectors are the authority this package settles wire-shape questions
against, so provenance that is merely usually true is not much use. It now
refuses to run unless that directory is clean - unstaged, staged and
untracked all checked.
That immediately earned its keep: SOURCE named f7d240b (v0.7.0-2) while
the matcher is at d68cf52 (v0.8.0). The 51 files are byte-identical
between the two, so nothing was wrong with what is vendored - only with
what it said about itself.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
The fallback asked the wrong question. It tried the typed decode and went
to Raw only when that ERRORED - but `"req": []` decodes perfectly well
into an empty slice, which omitempty then drops, and an item missing "id"
decodes to "" and comes back as a member that was not there. Those
survived the decode and did not survive the round trip, so the HA cache
quietly repaired them.
Which is the one thing this serialiser is not allowed to do. Those exact
shapes are fixtures for wallet behaviour - every planner rejects them, and
sending one is how a negative test is run - so repairing them makes the
tests they exist for impossible.
The test is now "does it encode back to what arrived", compared as JSON
values rather than bytes, since member order and whitespace are not
something this type promises to keep and treating them as loss would send
every ordinary query down the raw path. Numbers compare by literal, so a
value that re-encoded with different precision counts as changed - which,
for a req param, is what it is.
This retires a test rather than adding one.
TestAbsentAndEmptyAreNotDistinguishedInTheTypedModel documented the
lossiness as a boundary and said in as many words that if these shapes
ever round-tripped it could be deleted. They do now - not because the
struct gained a distinction, but because decoding stopped insisting on
using it. It is replaced by the opposite assertion, through the cache as
well as the serialiser, plus its counterpart: a shape the model DOES
represent faithfully must still decode typed, or the distinction stops
meaning anything and every query becomes opaque bytes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
The shadow was filled in on the decode path and, locally, inside
validation - which takes a value, so the caller's query kept its zeros.
The documented way to use the escape hatch, CredentialQuery{Raw: ...},
therefore produced a query that is SENT correctly and then tracked under
defaults nobody chose: UIInteraction records ID in the authorization
context's scopes and the response path matches a presentation back to it.
It fails as a result that cannot be matched, with nothing malformed to
point at.
NewRawCredentialQuery fills the shadow in, copies the bytes so a later
mutation of the caller's slice cannot change what is sent, and refuses
bytes that are not JSON here rather than at send time, where it would
surface as an unencodable request object far from the call that caused it.
"Callers should use the constructor" is advice, and advice does not
survive the next caller - which is how the shadow came to be two members
in the first place. So production code may not write the literal at all:
an AST walk over every non-test file in the repository fails on a
CredentialQuery composite literal with a Raw key outside the one file that
populates the shadow. It fails closed if the walk parses nothing.
Tests may write it, and should - asserting what a hand-built query does is
the subject of several of them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
"Emitted as exactly these bytes" was never true. encoding/json compacts
and HTML-escapes whatever a MarshalJSON returns on its way into the
enclosing document, so an operator's layout is dropped and an & inside a
string is written as its escape. Nothing here can prevent that short of
re-implementing the request-object encoder, and there is no reason to: a
conformant wallet cannot tell the difference.
What is actually guaranteed - every member, its nesting and every value
survives unchanged, including members this build has never heard of and
numeric literals it would otherwise reformat - is what the hatch is for,
so the claim is narrowed to that rather than the mechanism changed.
The tests could not have caught the overclaim, because they all compare
JSON semantically; that is the right comparison, and it means the byte
claim was never under test at all. So there is now one test asserting both
halves: that the configured layout and the literal ampersand do NOT reach
the wire, and that the value - claims as an object included - comes back
exactly, with the escaped ampersand reading as the string that was
configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
Clarify validity window does not provide revocation checking
docs/verifier/NYM_AUTH_VERIFICATION.md:345
This implies that the time window protects against revoked credentials, but this verification path checks MSO validity and issuer trust without checking credential revocation. A revoked but unexpired credential can still produce a fresh proof; describe the window as limiting backdating and explicitly distinguish it from revocation checking.
Use zkp_req instead of the rejected circuit_zk format
docs/verifier/README.md:73
The supported format is now zkp_req; circuit_zk is the pre-v0.4 name and is explicitly rejected in configuration. Update this capability label to match the linked guide and runtime.
Two key names from the v0.4 rename were changed on the Go side and not
in the browser. The first broke the live gdc verifier outright; the
second was behind it and would have broken the wallet round silently.
The item key. req-schema.js validated `typeof item.id === "string"`
while the server publishes `fn_id`, so parsing /ui/metadata threw
"req must be an array of {id, return, params?} items" and the page
never left loading. Server endpoints were all fine, which is why this
read as a front-end mystery rather than as the rename it was.
The req key. The browser copied the preset's `req` into a posted
`req`, and CredentialQuery.Req is tagged `_req`. encoding/json does
not match one to the other - it drops it - so the posted query would
have carried no items at all, and the request would have gone out
asking the wallet for nothing. No error at any layer. Proved with a
probe before fixing: posted "req" -> len(Req)=0, posted "_req" -> 1.
The two keys are not a mistake to unify. `req` is the preset key, this
repository's own configuration surface, and `_req` is v0.4's wire
member; the copy in handleSelectPredefinedPresentationDefinition is
the one place they meet, and both sides now say so in a comment.
The existing JS test passed throughout because its fixture was never
moved with the rename - it still built items with `id` and the old
`pseudonym-v1-sha256`. A fixture that was not updated is not a test
that was not written; it is worse, because it reports success. It now
pins both directions: the published shape is accepted and the shape it
replaced is refused.
Three guards, each proved by restoring the defect:
- the preset round trip drives the REAL /ui/metadata payload through
the browser's copy and into a CredentialQuery, then through
UIInteraction. Written over the published JSON, not the Go structs,
because both key names exist only in the JSON.
- the schema guard now requires `_req` in the DCQL schema, the literal
copy `c._req = cred.req`, and `item.fn_id` in req-schema.js.
- req-schema.test.js refuses an item carrying `id`.
Also finishes the rename in the generated configuration reference,
which still said circuit_zk in 20 places - including
`preferred_vp_formats.circuit_zk: {}` in a worked example an operator
would copy, and which startup now refuses by name. All prose; the only
literals that keep the old spelling are the legacy-key refusals, which
exist to name it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
…mauth
main gained the seed-commitments work (SUNET#793, 6f4a95c) plus several
merge PRs, which put a second copy of pkg/nymauth and seed_recover on
main - the copy from before the v0.4 rename and before the verifier_id
inversion. Every branch in this stack went CONFLICTING at once.
Resolved toward this branch for everything nym_auth, because main's
copy predates both decisions, and toward main for everything else.
Textual conflicts were small; one was not textual at all:
internal/wallet/apiv1/vci_presentation.go is new on main and reads
`!query.Multiple`, while this stack made Multiple a *bool so an absent
one is distinguishable from an explicit false. git merged the two
cleanly and the result did not compile. It now calls AllowsMultiple(),
which applies OpenID4VP 1.0 6.1's default of false - the same answer
the bool gave, for an absent member and an explicit false alike.
Two documents are left saying what this branch's own code says and
will be corrected where the rule changed, not here: the seed_recover
`-verifier-id` help and SEED_COMMITMENTS.md still describe the BARE
verifier_id. That is correct for this branch, whose checkVerifierID
still refuses the prefixed form; the inversion lives one branch up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
The first live Pixel round reached this verifier, was refused for a
reason the verifier had decided and could name - the wallet sent the DS
leaf plus the root, and these circuits take exactly one key - and the
wallet saw HTTP 500 internal_server_error with a bare req_id.
So the round was spent looking at the wallet for a decision made here.
Every one of the 32 refusal sites in this path returned a bare error,
and NewErrorFromError redacts what it does not recognise, which is
right for an unexpected failure and wrong for a verdict.
Stable codes now, in the detail-free style the shared matcher vectors
use, rendered as 400 and logged at WARN. A shape refusal keeps the
shared validator's OWN reason code rather than being renamed here, so a
wallet author compares one vocabulary against the vectors instead of
two.
What stays a 500 is the point. A verifier built without the zknative
tag, with no trust evaluator, with an allow-all evaluator, with a
verifier_id it will not derive from, or with a circuit that will not
load has not refused a presentation - it cannot judge one. Telling a
wallet "your presentation is bad" when the fault is ours sends its
author hunting for a defect that is not theirs, so those keep the
redacted 500 they should have. The split is one function,
zkpReqError, which every return in verifyCircuitZkScope goes through.
proof_time out of range carries the deviation and the window in its
detail: without them a wallet cannot tell a wrong clock from a stale
proof, and both land on the same code.
The key-set-size refusal now says what to send instead - the Document
Signer certificate alone - because the prover otherwise has to guess
which of its certificates to drop.
Tests: one per code, each proved non-vacuous by restoring the defect -
a bare error, a 500 status, a renamed shape code. A guard asserts every
declared code is exercised, reading both refusal test files because
three codes exist only behind the zknative tag; it immediately caught
five codes I had not covered, which is the same gap that put the 500
there to begin with.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum
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
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.
Stacked on #831 (on #830, on #829). Targets
mainlike the rest of the stack, so the diff shows the commits below it — review only the last commit. Merge after #831.This is the docs half of step 4. The operator-page half is on hold pending leifj's call on what it should show; nothing is built for it here.
Why
There was no verifier-side document at all.
docs/issuer/SEED_COMMITMENTS.mdcovers the issuing half and had no twin, so an operator turning this on had the configuration reference and nothing explaining why any of it matters.It is written around the failure mode rather than the feature, because this feature has an unusually nasty one: a pseudonym is only meaningful if every verifier computes it the same way, so a verifier that gets an input wrong produces a value that matches nothing — and a value that matches nothing reads as this is not the person you are looking for. The failure is a wrong answer about a person, not an error anyone sees. The document says that up front and then explains each refusal in those terms, with a table mapping the message an operator will actually see to its cause.
Covered
zknativebuild, without which nothing verifies at allverifier_id: the bare value, and the two markers that look like prefixes and are not the same kind of thingsscandpscare not interchangeablepreferred_vp_formats.zkp_req, where offering nothing refuses everythingtrust.pdp_url, and why allow-all is not an option heredirect_post.jwtrequirement over redirectsChecked, not written from memory
Every quoted message, YAML key and digest was verified against the code. Two claims did not survive that and were corrected before committing:
credential-issuer, notmdoc-issuer-auth— I had carried the latter over from the design brief's wording without checking the constant;498151230on feat(verifier): dispatch a zkp_req presentation to the nym_auth verifier #830, found by trying to document it.🤖 Generated with Claude Code
https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum