From 94a6e6732321c6f110dbbf4c34a79573cf113a3e Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Wed, 21 Oct 2026 19:50:21 +0200 Subject: [PATCH] fix(nymauth)!: convert main to rulebook v0.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main gained pkg/nymauth and seed_recover through #793, and that code predates two v0.4 decisions. It is the pre-rename, pre-inversion copy. BREAKING: verifier_id is now the PREFIXED string, and SHA-256 of the whole thing - prefix included - is verifier_input: SHA256("x509_san_dns:verifier.example") = 6eae85a0…6bab (MUST) SHA256("verifier.example") = be6a6400…4a41 (not a verifier_id) Both recomputed here rather than copied. The pair was inverted once already, and a digest is not something a reader checks by eye. Why this is worth doing before the verifier stack lands. On main this code is reachable from exactly one place: developer_tools/scripts/ seed_recover, the tool whose job is deciding whether a reported pseudonym belongs to a given subject. As merged it refuses the prefixed verifier_id and hashes the bare DNS name, so against a v0.4 Verifier it computes a pseudonym nobody reports - and answers "no match" for the right subject. That is a wrong answer about a person, and it is the answer the tool exists to give. The documentation was worse than the code. SEED_COMMITMENTS.md told operators to pass the bare value in three places, including the main worked command line, and published the inverted digests as the rule. Someone following that page could not run the tool at all after this change - and before it, would have been quietly given the wrong answer. So the page is now checked rather than proofread. Two guards, each proved by restoring the defect: - every `-verifier-id` the document shows is run through CheckVerifierID. It found the third bare example, in the worked command line, which this change had otherwise missed. - the worked digests are recomputed and the page must mark the PREFIXED form as the one hashed - both digests appear on the page, so finding one proves nothing about which it recommends. Also corrects a comment in pseudonym_test.go that still asserted the bare form in prose while the test below it asserted the prefixed one. It survived the inversion; it is replaced rather than deleted, because this is the kind of reversal a reader assumes is a mistake and "fixes" back. Nothing is normalised - not case, not a trailing dot, not an IDN. Where v0.4 is silent, this hashes the bytes it was given; the questions are filed with the rulebook author as items 24-27 of sirosfoundation/nym_auth_rulebook#10 rather than answered locally. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01AB3apg5kCEGNPozrhSggum --- .../seed_recover/doc_verifier_id_test.go | 74 +++++++++ developer_tools/scripts/seed_recover/main.go | 2 +- .../scripts/seed_recover/main_test.go | 4 +- docs/issuer/SEED_COMMITMENTS.md | 59 +++---- pkg/nymauth/pseudonym.go | 144 ++++++++++++------ pkg/nymauth/pseudonym_test.go | 117 ++++++++------ 6 files changed, 272 insertions(+), 128 deletions(-) create mode 100644 developer_tools/scripts/seed_recover/doc_verifier_id_test.go diff --git a/developer_tools/scripts/seed_recover/doc_verifier_id_test.go b/developer_tools/scripts/seed_recover/doc_verifier_id_test.go new file mode 100644 index 000000000..9cec7066b --- /dev/null +++ b/developer_tools/scripts/seed_recover/doc_verifier_id_test.go @@ -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") +} diff --git a/developer_tools/scripts/seed_recover/main.go b/developer_tools/scripts/seed_recover/main.go index cbbb7e769..6c9c1fca0 100644 --- a/developer_tools/scripts/seed_recover/main.go +++ b/developer_tools/scripts/seed_recover/main.go @@ -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: 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: or semantic_id:. 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") diff --git a/developer_tools/scripts/seed_recover/main_test.go b/developer_tools/scripts/seed_recover/main_test.go index 904349553..9bdfdcbd9 100644 --- a/developer_tools/scripts/seed_recover/main_test.go +++ b/developer_tools/scripts/seed_recover/main_test.go @@ -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) } diff --git a/docs/issuer/SEED_COMMITMENTS.md b/docs/issuer/SEED_COMMITMENTS.md index f1592c1c7..daca7ff30 100644 --- a/docs/issuer/SEED_COMMITMENTS.md +++ b/docs/issuer/SEED_COMMITMENTS.md @@ -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 ``` @@ -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: +semantic_id: ``` -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:`, -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 diff --git a/pkg/nymauth/pseudonym.go b/pkg/nymauth/pseudonym.go index 98fe66676..e0b972239 100644 --- a/pkg/nymauth/pseudonym.go +++ b/pkg/nymauth/pseudonym.go @@ -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/", UTF-8 @@ -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:" 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:" 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 @@ -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) { diff --git a/pkg/nymauth/pseudonym_test.go b/pkg/nymauth/pseudonym_test.go index 503db670f..54dcb352f 100644 --- a/pkg/nymauth/pseudonym_test.go +++ b/pkg/nymauth/pseudonym_test.go @@ -35,11 +35,11 @@ func TestPseudonymIsTheSpecifiedConstruction(t *testing.T) { // under two configurations is unrelated - which is what stops an account // recovery pseudonym being usable for offender matching. func TestConfigurationSeparatesPseudonyms(t *testing.T) { - a, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example") + a, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) - b, err := KnownOffenderMatching(ConfigKnownOffenderMatchingPSC, testSeed, "rp.example") + b, err := KnownOffenderMatching(ConfigKnownOffenderMatchingPSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) - c, err := AccountRecovery(testSeed, "rp.example", "account-1") + c, err := AccountRecovery(testSeed, "x509_san_dns:rp.example", "account-1") require.NoError(t, err) assert.NotEqual(t, a, b) @@ -50,13 +50,13 @@ func TestConfigurationSeparatesPseudonyms(t *testing.T) { // Known-offender matching only works if one Verifier always sees the same // pseudonym for one person, and different Verifiers never do. func TestKnownOffenderMatchingPerVerifier(t *testing.T) { - first, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example") + first, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) - again, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example") + again, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example") require.NoError(t, err) assert.Equal(t, first, again) - other, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "other.example") + other, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:other.example") require.NoError(t, err) assert.NotEqual(t, first, other) @@ -64,7 +64,7 @@ func TestKnownOffenderMatchingPerVerifier(t *testing.T) { // (This used to assert that the rulebook's two PREFIXED forms of one // Relying Party were unrelated; the prefix is no longer hashed, so // what is left is the plain property that different ids differ.) - nearby, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "rp.example.semantic") + nearby, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:rp.example.semantic") require.NoError(t, err) assert.NotEqual(t, first, nearby) } @@ -72,9 +72,9 @@ func TestKnownOffenderMatchingPerVerifier(t *testing.T) { // Account recovery is per verifier AND per context, which is what makes // it per account rather than per person. func TestAccountRecoveryIsPerContext(t *testing.T) { - one, err := AccountRecovery(testSeed, "rp.example", "account-1") + one, err := AccountRecovery(testSeed, "x509_san_dns:rp.example", "account-1") require.NoError(t, err) - two, err := AccountRecovery(testSeed, "rp.example", "account-2") + two, err := AccountRecovery(testSeed, "x509_san_dns:rp.example", "account-2") require.NoError(t, err) assert.NotEqual(t, one, two) } @@ -94,17 +94,17 @@ func TestInputValidation(t *testing.T) { _, err = Pseudonym("c", testSeed, good, good[:31]) assert.Error(t, err) - _, err = KnownOffenderMatching(ConfigAccountRecoverySSC, testSeed, "rp.example") + _, err = KnownOffenderMatching(ConfigAccountRecoverySSC, testSeed, "x509_san_dns:rp.example") assert.Error(t, err, "account recovery has a different verifier_input and must not come through here") _, err = KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "") assert.Error(t, err) // The rulebook bounds verifier_context at 1-64 bytes. - _, err = AccountRecovery(testSeed, "rp.example", "") + _, err = AccountRecovery(testSeed, "x509_san_dns:rp.example", "") assert.Error(t, err) - _, err = AccountRecovery(testSeed, "rp.example", string(bytes.Repeat([]byte("c"), 65))) + _, err = AccountRecovery(testSeed, "x509_san_dns:rp.example", string(bytes.Repeat([]byte("c"), 65))) assert.Error(t, err) - _, err = AccountRecovery(testSeed, "rp.example", string(bytes.Repeat([]byte("c"), 64))) + _, err = AccountRecovery(testSeed, "x509_san_dns:rp.example", string(bytes.Repeat([]byte("c"), 64))) assert.NoError(t, err) _, err = UserManagedXsite(testSeed, "") @@ -137,7 +137,7 @@ func TestSeedLengthIsFixedByTheRulebook(t *testing.T) { assert.Error(t, err, "a %d-byte seed must be refused", n) } - _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, bytes.Repeat([]byte{7}, 32), "rp.example") + _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, bytes.Repeat([]byte{7}, 32), "x509_san_dns:rp.example") assert.Error(t, err, "the configuration helpers must refuse it too, not just Pseudonym") _, err = Pseudonym("c", bytes.Repeat([]byte{7}, SeedBytes), good, good) @@ -150,31 +150,45 @@ func TestSeedLengthIsFixedByTheRulebook(t *testing.T) { // Verifier actually reported would never match, and the operator would // conclude the subject is not the one they are looking for. A wrong // answer, not an error. -// The hashed verifier input is the BARE verifier_id. +// The hashed verifier input is the PREFIXED verifier_id. // -// This test used to assert the opposite - that a prefixed Client -// Identifier was the form to hash - and it was wrong. The rulebook -// states the bare form with worked digests and marks the prefixed one -// MUST NOT; verifier_id_type is agreed out of band and is not hashed. -// Hashing the prefix produces a 32-byte value indistinguishable from a -// correct pseudonym that no Verifier will ever report, so a remapping -// loop would simply never match and an operator would conclude the -// subject is innocent. -func TestVerifierInputIsTheBareVerifierID(t *testing.T) { +// This paragraph asserted the opposite until now, having survived the +// change below it: rulebook v0.4 inverted the rule, the digests and the +// refusals were corrected, and the sentence explaining them was not. It +// is worth replacing rather than deleting, because the inversion is the +// kind a reader assumes is a mistake and "fixes" back. +// +// Either way round, the cost of being wrong is the same and is never an +// error: hashing the other form yields a 32-byte value indistinguishable +// from a correct pseudonym that no Verifier will ever report, so a +// remapping loop never matches and an operator concludes the subject is +// innocent. +func TestVerifierInputIsThePrefixedVerifierID(t *testing.T) { // The rulebook's worked digests, recomputed here rather than // copied as opaque constants: if either moves, this says which. bare := sha256.Sum256([]byte("verifier.example")) prefixed := sha256.Sum256([]byte("x509_san_dns:verifier.example")) - require.Equal(t, "be6a6400", hex.EncodeToString(bare[:])[:8], - "the rulebook's MUST digest for the bare verifier_id") require.Equal(t, "6eae85a0", hex.EncodeToString(prefixed[:])[:8], - "and its MUST NOT digest for the prefixed Client Identifier") + "rulebook v0.4's MUST digest for the prefixed verifier_id, and the archive's own verifier_input") + require.Equal(t, "be6a6400", hex.EncodeToString(bare[:])[:8], + "and the bare DNS name, which v0.4 says is NOT a verifier_id") for _, bad := range []string{ - "x509_san_dns:rp.example", // the form the rulebook marks MUST NOT + // A marker with nothing after it names no Verifier. "x509_san_dns:", - "semantic_id:", // the semantic form naming nobody + "semantic_id:", "", + // And rulebook v0.4 defines exactly two forms, so a value + // starting with neither is refused rather than hashed. The + // bare DNS name is the one that matters in practice: it was + // the CORRECT value until 2026-10-20, so any configuration + // written before then still carries it. + "rp.example", + "verifier.example", + "sirosid-gdc-vc-verifier.fly.dev", + "https://rp.example/path", + "x509_hash:abcd", + "X509_SAN_DNS:rp.example", } { _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, bad) assert.Errorf(t, err, "verifier_id %q is not what the rulebook hashes", bad) @@ -187,27 +201,25 @@ func TestVerifierInputIsTheBareVerifierID(t *testing.T) { // the guard is about the Client Identifier Prefix, not about // punctuation. // - // "semantic_id:rp.example" is in this list on purpose. The rulebook - // says that string is part of the `semantic` verifier_id VALUE and - // not a Client Identifier Prefix, so it is hashed whole. Refusing it - // would leave every semantic deployment unable to derive - // verifier_input, and the failure would look like a bad pseudonym - // rather than a rejected identifier. + // The two forms rulebook v0.4 defines, hashed exactly as + // published. What follows the marker is opaque: the rulebook + // constrains the form, not the identifier inside it. for _, good := range []string{ - "rp.example", - "verifier.example", + "x509_san_dns:rp.example", + "x509_san_dns:verifier.example", + "x509_san_dns:a.b.c.d.e", "semantic_id:rp.example", - "https://rp.example/path", - "x509_hash:abcd", + "semantic_id:age-over-18-check", + "semantic_id:anything at all, even with spaces", } { _, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, good) - assert.NoErrorf(t, err, "%q is a bare verifier_id", good) + assert.NoErrorf(t, err, "%q is one of the two defined forms", good) } // And a semantic verifier_id is hashed WHOLE: the digest is over - // "semantic_id:rp.example", not over "rp.example". + // "semantic_id:rp.example", not over "x509_san_dns:rp.example". semantic := sha256.Sum256([]byte("semantic_id:rp.example")) - identifierOnly := sha256.Sum256([]byte("rp.example")) + identifierOnly := sha256.Sum256([]byte("x509_san_dns:rp.example")) require.NotEqual(t, semantic, identifierOnly) gotSemantic, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "semantic_id:rp.example") require.NoError(t, err) @@ -215,13 +227,20 @@ func TestVerifierInputIsTheBareVerifierID(t *testing.T) { require.NoError(t, err) assert.Equal(t, wantSemantic, gotSemantic) - // And the pseudonym really is computed over the bare form: the same - // Relying Party written the prefixed way is refused, so there is no - // pair of inputs that both succeed and disagree. - fromBare, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "verifier.example") + // The pseudonym really is derived from the PREFIXED string, with + // nothing added and nothing removed. + fromPrefixed, err := KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "x509_san_dns:verifier.example") require.NoError(t, err) - want, err := Pseudonym(ConfigKnownOffenderMatchingSSC, testSeed, nullInput, bare[:]) + want, err := Pseudonym(ConfigKnownOffenderMatchingSSC, testSeed, nullInput, prefixed[:]) require.NoError(t, err) - assert.Equal(t, want, fromBare, - "verifier_input must be SHA256 of the bare id, with nothing prepended") + assert.Equal(t, want, fromPrefixed, + "verifier_input must be SHA256 of the verifier_id exactly as published") + + // And the bare DNS name is not a verifier_id at all, so it never + // reaches a digest - which is what stops a configuration written + // before the reversal from silently producing wrong pseudonyms. + _, err = KnownOffenderMatching(ConfigKnownOffenderMatchingSSC, testSeed, "verifier.example") + require.Error(t, err) + assert.Contains(t, err.Error(), "x509_san_dns:verifier.example", + "the refusal should name the value the operator probably meant") }