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") }