Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions developer_tools/scripts/seed_recover/doc_verifier_id_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package main

import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

"github.com/SUNET/vc/pkg/nymauth"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func seedCommitmentsDoc(t *testing.T) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "issuer", "SEED_COMMITMENTS.md"))
require.NoError(t, err)
return string(raw)
}

// Every -verifier-id the document hands an operator has to be one this
// tool accepts.
//
// This is the failure the document actually shipped: it told the reader
// to pass the BARE verifier_id, which rulebook v0.4 refuses. An operator
// following the page could not run the tool at all - and the earlier
// version of that same page, which the bare value came from, would have
// had them compute a pseudonym no Verifier reports and conclude a
// subject was innocent.
func TestEveryVerifierIDTheDocumentSuggestsIsOneTheToolAccepts(t *testing.T) {
flagExample := regexp.MustCompile(`-verifier-id\s+(\S+)`)

matches := flagExample.FindAllStringSubmatch(seedCommitmentsDoc(t), -1)
require.NotEmpty(t, matches, "the document no longer shows a -verifier-id example; this guard is reading the wrong file")

for _, match := range matches {
value := match[1]
t.Run(value, func(t *testing.T) {
assert.NoErrorf(t, nymauth.CheckVerifierID(value),
"the document tells an operator to pass %q, which this tool refuses", value)
})
}
}

// The document publishes two worked digests, one of them as the value to
// use. A digest is not something a reader can check by eye, so a wrong
// one is believed - and this pair was inverted once already, by v0.4.
func TestTheDocumentsWorkedDigestsAreReal(t *testing.T) {
doc := seedCommitmentsDoc(t)

for _, tc := range []struct{ input, prefix string }{
{"x509_san_dns:verifier.example", "6eae85a0"},
{"verifier.example", "be6a6400"},
} {
t.Run(tc.input, func(t *testing.T) {
sum := sha256.Sum256([]byte(tc.input))
full := hex.EncodeToString(sum[:])
require.Truef(t, strings.HasPrefix(full, tc.prefix),
"this guard's own expectation is stale: SHA256(%q) starts %s, not %s", tc.input, full[:8], tc.prefix)
assert.Containsf(t, doc, tc.prefix,
"the document no longer shows the digest of %q", tc.input)
})
}

// Which of the two is the one to USE. Both digests appear on the
// page, so finding one proves nothing about which it recommends.
assert.Contains(t, doc, `SHA256("x509_san_dns:verifier.example") = 6eae85a0…6bab (MUST)`,
"the document must mark the PREFIXED form as the one that is hashed")
assert.NotContains(t, doc, `SHA256("verifier.example") = be6a6400…4a41 (MUST)`,
"the document marks the bare DNS name as the value to hash, which v0.4 inverted")
}
2 changes: 1 addition & 1 deletion developer_tools/scripts/seed_recover/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func run() error {
subjects subjectList
derivContext = flag.String("context", "", "the derivation context, if the credential configuration sets one")
pseudonymConfig = flag.String("pseudonym-config", "", "nym_auth pseudonym configuration to compute, e.g. known-offender-matching-ssc")
verifierID = flag.String("verifier-id", "", "the verifier_id EXACTLY as the Verifier publishes it: bare (verifier.example) for an x509_san_dns Verifier, or semantic_id:<identifier> for a semantic one - never the prefixed client_id")
verifierID = flag.String("verifier-id", "", "the verifier_id EXACTLY as the Verifier publishes it, prefix included: x509_san_dns:<dns name> or semantic_id:<identifier>. Rulebook v0.4 defines no other form, and the bare DNS name is not a verifier_id")
verifierContext = flag.String("verifier-context", "", "verifier_context, required for account-recovery-ssc")
userValue = flag.String("user-value", "", "the user-supplied linking value, for user-managed-pseudonym-xsite-ssc")
expect = flag.String("expect", "", "a pseudonym in hex to compare against; exit 0 on a match, 2 on a mismatch")
Expand Down
4 changes: 2 additions & 2 deletions developer_tools/scripts/seed_recover/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,13 @@ func TestLoadSeedSourceReadsTheSecretsFile(t *testing.T) {
func TestPSCIsRefusedRatherThanComputedFromTheWrongSeed(t *testing.T) {
seedValue := bytes.Repeat([]byte{7}, 16)

_, err := computePseudonym(nymauth.ConfigKnownOffenderMatchingPSC, seedValue, "verifier.example", "", "")
_, err := computePseudonym(nymauth.ConfigKnownOffenderMatchingPSC, seedValue, "x509_san_dns:verifier.example", "", "")
require.Error(t, err, "this tool does not hold the holder's private_seed")
assert.Contains(t, err.Error(), "holder-held")

// The ssc configuration it CAN reproduce still works, or the
// refusal above would be indistinguishable from refusing everything.
got, err := computePseudonym(nymauth.ConfigKnownOffenderMatchingSSC, seedValue, "verifier.example", "", "")
got, err := computePseudonym(nymauth.ConfigKnownOffenderMatchingSSC, seedValue, "x509_san_dns:verifier.example", "", "")
require.NoError(t, err)
assert.Len(t, got, 32)
}
Expand Down
59 changes: 33 additions & 26 deletions docs/issuer/SEED_COMMITMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ make build-seed-recover
./bin/seed_recover -config /etc/vc/config.yaml -seed primary \
-subject SE -subject 198001011234 \
-pseudonym-config known-offender-matching-ssc \
-verifier-id verifier.example \
-verifier-id x509_san_dns:verifier.example \
-expect <pseudonym the verifier reported>
```

Expand All @@ -313,40 +313,47 @@ there is nothing to compare against, and exiting 0 regardless would report
a match for every candidate. The seed itself is printed only with
`-show-seed`.

`-verifier-id` is the **bare** verifier_id — `verifier.example` — not the
prefixed Client Identifier and not `client_id` as it appears in the
request. `verifier_id_type` is agreed out of band between wallet and
verifier and is not hashed. The rulebook states this with worked digests
and marks the prefixed form MUST NOT:
`-verifier-id` is the verifier_id **exactly as the Verifier publishes it,
prefix and all**. Rulebook v0.4 defines two forms and no others, and the
whole string is what is hashed:

```
SHA256("verifier.example") = be6a6400…4a41 (MUST)
SHA256("x509_san_dns:verifier.example") = 6eae85a0…6bab (MUST NOT)
x509_san_dns:<dns name>
semantic_id:<identifier>
```

A value carrying `x509_san_dns:` is therefore **refused** rather than
stripped. Hashing it would succeed and return a pseudonym no Verifier could
have reported, so the loop would simply never match and the operator would
conclude the subject is innocent — and stripping it would quietly accept a
caller that is confused about which value it holds.
The worked digests, which v0.4 inverted relative to earlier drafts:

**`semantic_id:` is not the same kind of thing, and must NOT be stripped.**
It looks like a Client Identifier Prefix and is not one: for a semantic
Verifier the verifier_id *is* the whole string `semantic_id:<identifier>`,
and that whole string is what is hashed. Pass it exactly as the Verifier
publishes it.
```
SHA256("x509_san_dns:verifier.example") = 6eae85a0…6bab (MUST)
SHA256("verifier.example") = be6a6400…4a41 (the bare DNS
name, which is
NOT a verifier_id)
```

A value that is neither form is **refused** rather than repaired. The bare
DNS name was the correct value until 2026-10-20, so a script or a runbook
written before then still passes one, and it still looks entirely
plausible. Hashing it would succeed and return a pseudonym no Verifier
could have reported: the loop would never match, and the operator would
conclude the subject is innocent. That is a wrong answer about a person
rather than an error anyone sees, which is why this refuses instead of
stripping or guessing.

```
-verifier-id verifier.example # x509_san_dns Verifier: bare, no prefix
-verifier-id semantic_id:age-over-18-check # semantic Verifier: the marker is part of the value
-verifier-id x509_san_dns:verifier.example # an x509_san_dns Verifier
-verifier-id semantic_id:age-over-18-check # a semantic Verifier
```

So the rule is not "strip anything before a colon". It is: a Client
Identifier Prefix is never hashed, and a semantic identifier always is.
Getting this backwards fails the same silent way as the prefixed form —
a pseudonym no Verifier reports, a loop that never matches, and a subject
who looks innocent. The one case refused is a bare `semantic_id:` with
nothing after it, which names no Verifier at all.
Nothing is stripped and nothing is normalised — not case, not a trailing
dot, not an internationalised name. Where v0.4 is silent on those, this
hashes the bytes it was given; the questions are filed with the rulebook
author rather than answered locally.

For a semantic Verifier the identifier is taken from the registration
certificate when one is present, and from the access certificate
otherwise. The one value refused for naming no Verifier at all is a bare
prefix with nothing after it.

It reads the issuer's config and secret, so it has to run where those are.
That is the intended restriction rather than an inconvenience: whoever can
Expand Down
144 changes: 94 additions & 50 deletions pkg/nymauth/pseudonym.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const SeedBytes = 16
// after it and produce a different pseudonym.
var nullInput = make([]byte, 32)

// Pseudonym computes pseudonym-v1-sha256:
// Pseudonym computes pseudonym:
//
// pseudonym = SHA256(DST || seed || prover_input || verifier_input)
// DST = "nym_auth/v1/<config>", UTF-8
Expand Down Expand Up @@ -100,64 +100,108 @@ const (
// checkVerifierID refuses an identifier no conforming Verifier could have
// reported.
//
// The hashed input is the BARE verifier_id - "verifier.example" for an
// x509_san_dns Verifier, "semantic_id:<identifier>" for a semantic one -
// never the prefixed Client Identifier and never client_id as it appears
// in the request. verifier_id_type is agreed out of band and is not
// hashed.
//
// "Bare" is about the Client Identifier Prefix, not about colons. A
// semantic verifier_id carries "semantic_id:" as part of its own value
// and is hashed whole; refusing it would leave semantic deployments
// unable to derive verifier_input at all.
//
// WHICH RULEBOOK: nym_auth_rulebook PR #7, "verifier_input: SHA-256 over
// the bare verifier_id, never the prefixed client_id" (stacked on #5).
// Pinned explicitly because the published v0.3 on that repo's main says
// the opposite - its matching and account-recovery formulas hash the
// full prefixed identifier - so a reader comparing this against main
// will find a contradiction rather than a bug. #7 supersedes it, states
// the rule with worked digests, and marks the prefixed form MUST NOT:
//
// SHA256("verifier.example") = be6a6400...4a41 (MUST)
// SHA256("x509_san_dns:verifier.example") = 6eae85a0...6bab (MUST NOT)
//
// #7 also records that the nym_auth circuits SPEC section 3 still shows
// the prefixed form and that its reference values need regenerating; the
// prefix is not bound inside the circuit, which takes verifier_input as
// a 32-byte digest public input, so a conforming verifier passing the
// bare digest verifies against current circuit builds.
//
// So a PREFIXED value is refused here, which is the opposite of what this
// function did when it was written against the earlier reading. The
// reason for refusing rather than stripping is unchanged: hashing
// whatever it is given succeeds and returns a 32-byte pseudonym
// The hashed input is the verifier_id EXACTLY AS PUBLISHED, prefix and
// all: "x509_san_dns:verifier.example" for an x509_san_dns Verifier,
// "semantic_id:<identifier>" for a semantic one. Nothing is stripped
// and nothing is normalised - not case, not a trailing dot.
//
// WHICH RULEBOOK: v0.4, and the archive circuits agree with it. This is
// the REVERSE of what this function did until 2026-10-20, when it
// hashed the bare DNS name and refused the prefixed form. Both readings
// have been written down at different times, so the worked digests are
// here to settle which one this code implements:
//
// SHA256("x509_san_dns:verifier.example") = 6eae85a0...6bab (MUST)
// SHA256("verifier.example") = be6a6400...4a41 (MUST NOT)
//
// The archive's own reference values are the tie-breaker: its
// pseudonym.json carries client_id "x509_san_dns:verifier.example" with
// verifier_input 6eae85a0..., which is the prefixed digest.
//
// What is refused is a value that names no Verifier: an empty one, or a
// marker with nothing after it. Those hash cleanly - every wrong value
// here does - and a 32-byte digest from a meaningless input is
// indistinguishable from a correct one, so a remapping loop comparing
// against a Verifier's real pseudonym would simply never match, and the
// against a Verifier's real pseudonym would simply never match and the
// operator would conclude the subject is not the one they are looking
// for. A wrong answer, not an error.
//
// Stripping the prefix would be the tempting repair and is worse: it
// would accept a caller that is confused about which value it holds, and
// a caller confused about that is one whose OTHER inputs deserve a second
// look.
// CheckVerifierID is checkVerifierID for callers outside this package -
// the verifier side, which derives verifier_input from the same value
// and must refuse the same inputs. One rule, one implementation: a
// second copy is how the issuer and the verifier end up hashing
// different things and blaming each other's pseudonyms.
func CheckVerifierID(verifierID string) error { return checkVerifierID(verifierID) }

func checkVerifierID(verifierID string) error {
if verifierID == "" {
return fmt.Errorf("nymauth: no verifier_id")
}
if strings.HasPrefix(verifierID, VerifierIDX509SANDNS) {
return fmt.Errorf("nymauth: verifier_id %q carries the Client Identifier prefix %q; "+
"the hashed input is the bare verifier_id, and hashing the prefixed form yields a pseudonym no Verifier reports",
verifierID, VerifierIDX509SANDNS)

// Rulebook v0.4 defines exactly TWO forms. Anything else is
// refused rather than hashed.
//
// This is the half that went missing when the rule reversed on
// 2026-10-20. Before, a bare DNS name was the correct value and
// the prefixed form was refused; after, the prefixed form is
// correct - and nothing was left checking the shape at all, so a
// configuration still carrying the old bare value started
// cleanly and produced pseudonyms no Verifier reports. Found by
// sirosid-dev starting a gdc image with
// "sirosid-gdc-vc-verifier.fly.dev" configured.
//
// Fail closed, and no normalisation: the rulebook names two
// forms, so a third is a value nobody agreed the meaning of.
for _, prefix := range []string{VerifierIDX509SANDNS, VerifierIDSemantic} {
if verifierID == prefix {
// The marker with nothing after it names no Verifier. It
// hashes cleanly, like every other wrong value here, so
// it is refused rather than turned into a pseudonym
// nobody reports.
return fmt.Errorf("nymauth: verifier_id %q is the %s prefix with no identifier after it",
verifierID, strings.TrimSuffix(prefix, ":"))
}
if strings.HasPrefix(verifierID, prefix) {
return nil
}
}

// A value that looks like a host is the likely mistake, and the
// likely fix is one prefix - so say it, rather than leaving the
// operator to infer it from the list of forms.
hint := ""
if looksLikeDNSName(verifierID) {
hint = fmt.Sprintf("; it looks like a DNS name, so the verifier_id is probably %q",
VerifierIDX509SANDNS+verifierID)
}
return fmt.Errorf("nymauth: verifier_id %q starts with neither %q nor %q, and rulebook v0.4 defines no other form%s",
verifierID, VerifierIDX509SANDNS, VerifierIDSemantic, hint)
}

// looksLikeDNSName reports whether a value is plausibly a hostname
// someone forgot to prefix: dot-separated labels of letters, digits
// and hyphens, and nothing else.
//
// Only used to add a hint to a refusal, so a false negative costs a
// sentence and a false positive costs nothing - it is never a reason
// to accept a value.
func looksLikeDNSName(value string) bool {
if value == "" || strings.ContainsAny(value, ":/ ") || !strings.Contains(value, ".") {
return false
}
if verifierID == VerifierIDSemantic {
// The semantic form with nothing after the colon names no
// Verifier. It hashes cleanly, like every other wrong value
// here, so it is refused rather than turned into a pseudonym
// nobody reports.
return fmt.Errorf("nymauth: verifier_id %q is the semantic form with no identifier after it", verifierID)
for _, label := range strings.Split(value, ".") {
if label == "" {
return false
}
for _, r := range label {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
default:
return false
}
}
}
return nil
return true
}

// KnownOffenderMatching computes the pseudonym a Verifier identified by
Expand All @@ -167,7 +211,7 @@ func checkVerifierID(verifierID string) error {
//
// prover_input is null; verifier_input is SHA256(verifier_id).
//
// verifierID is the BARE identifier - "verifier.example" - without the
// verifierID is the published identifier - "x509_san_dns:verifier.example" - including the
// Client Identifier prefix and without verifier_id_type, which is agreed
// out of band and not hashed. See checkVerifierID.
func KnownOffenderMatching(config string, seed []byte, verifierID string) ([]byte, error) {
Expand Down
Loading
Loading