feat(mint): let a minted token carry a caller-asserted email - #68
Open
42-v wants to merge 9 commits into
Open
Conversation
42-v
force-pushed
the
feat/mint-email-claim
branch
4 times, most recently
from
August 26, 2026 15:11
6a4ba2a to
2c185d4
Compare
/mint exists because eleven services hold foreign-key copies of a platform's own user ids, and a relying party keyed on those ids can use a token that names only an opaque subject. A relying party keyed on email cannot. BeOn3 is the second kind. Its storage worker derives every avatar's owner key as GUID(SHA256(userId+email)), and six of its services read the claim. A missing email there does not fail loudly; it silently detaches every object from its owner. So the claim is available, and everything about how it is available says it is an assertion rather than a fact. Off by default, VAULT_MINT_ALLOW_EMAIL. Every other minted claim is a name vault42 issued or an operator allow-listed. An email is a caller-supplied identifier for a subject vault42 has never heard of, so enabling it says the operator trusts this client to assert who its users are -- a larger statement than trusting it to name a subject id, and one that should be made rather than inherited from a default. Refused, not stripped. A request carrying an email while the setting is off answers 403 email_not_permitted, and a malformed address answers 400 invalid_email. Two codes because they are two different mistakes: the first is a client pointed at the wrong deployment or at an operator who has not made that decision yet, the second is a bug in the caller. This is the call the role allow-list already makes, for the reason it records: a signing oracle that quietly issues something other than what was asked for hides the misconfiguration that produced the request. Normalised, lower-cased and trimmed, before it is signed. Nothing here looks the address up, but the relying party will look it up in its own table, and handing two spellings of one address to two callers is how that table grows two rows for one person. sanitize.Email does the validating and stays a validator rather than becoming a normalizer: homoglyphs and bidi controls remain distinct byte strings, which is the contract tests/attack/unicode_email_test.go pins. Not in the audit row. Audit entries outlive an erasure by design, and this is caller-asserted personal data vault42 never verified and keeps no other record of. Writing it there would create one, in the single table built to survive the subject asking to be forgotten. The row still names the subject, the client, the roles and the scopes, as it did. Not on any other token. The claim is set only on the /mint path and carries omitempty, so no login, refresh or client-credentials token grows an email field. A reader that treats the claim's presence as "vault42 asserted an address" is wrong, and docs/api.md says so in the claims table rather than leaving it to be inferred. The field is added at the end of VaultClaims deliberately: the register carries prose citations by line into internal/crypto/jwt.go, and appending moves none of them. The Config struct field does move fourteen, which scripts/register-reanchor.py --apply re-derived.
The Frontend suite holds a gate I had not met: errorMessages.test.ts reads every error code the Go handlers can emit and fails when one has no copy in the Vue app. Adding email_not_permitted to the server without adding it here left the frontend rendering its unknown-error fallback for a refusal that has a precise cause. It maps to error.forbidden, which is where its three siblings already point -- scope_not_permitted, role_not_permitted and shared_visibility_disabled. That reuses an existing i18n key, so none of the 38 locale catalogues change. invalid_email needed nothing: errorMessages.ts:52 has carried it since long before this, for the registration path.
…se the floor Three consequences of inserting checkEmail into mint.go and a claim comment into jwt.go. None is about the feature; all three are the coverage gate holding line-keyed entries to the tree. The floor. 12951 -> 12969. internal/service 1794 -> 1810 is checkEmail and ValidateMintEmail, the opt-in gate and the normalise-then-validate helper; internal/handler 1919 -> 1921 is the two new arms of mintErrorCode. Nothing else grew and no statement changed package. BASELINE_PACKAGE_STATEMENTS moves with the total and the comment above them carries the accounting, which is what that comment asks for. The displaced exclusion. checkRoles' defence-in-depth return sat at mint.go:393. checkEmail went in above it, so the frozen text now occurs at :419, and line 393 became a different statement that is covered -- which is why the gate reported both "the statement moved" and "now COVERED, delete the exclusion" about the same entry. It is one problem with two symptoms: the entry names a line, the line moved. Relocated to :419, verified by the source text being byte-identical including its two-tab indent, which is what distinguishes it from the three-tab occurrence at :412 inside the loop. Its justification cited mint.go:382 for the early return that keeps requested non-empty. That is :407 now. The third. scripts/cov-gaps.py's own accounting comment cited internal/crypto/jwt.go:131 as the modulus fallback in KIDFromPublicKey. The Email field's doc comment pushed it to :147, leaving the citation on a comment line. Re-derived.
The misspell linter runs with locale US (.golangci.yml says the tree is predominantly US spelling and that switching raises the finding count from 115 to 413), so behaviour and normalised are findings in Go source however normal they look in the prose around them. On mint-email: also restored the doc comment above AuditRepo.Insert. Inserting the actorColumns helper above the function left the comment attached to the helper, and revive caught the exported method with no comment. Only the audit branch has that half.
Five defects from an adversarial review of this branch. api.md omitted the email field from the POST /mint success example and from the response field table, while MintResponse has shipped it since the first commit here. spec.md makes api.md authoritative for response bodies, so that was the authoritative description of the object missing a field the server sends. Both renderings now carry it, and a new spec gate reads MintResponse's json tags out of the source and fails when either one omits a field the type can marshal. It parses the struct rather than marshalling a response, because omitempty means a runtime body only proves which fields one request populated. The "authenticated client, never anything from the body" comment had ended up directly above Email: req.Email, which is from the body and does have a field. It belongs to MintedBy and is back on it. The previous commit on this branch is titled "keep Insert next to its comment"; it fixed this same class of defect one branch over and missed it here. ValidateMintEmail's doc claimed FuzzMintRequestJSON calls it directly, as ValidateMintSubject does. It did not -- the only fuzz change was adding "email" to the accepted-field list, which is purely permissive. Rather than delete the sentence, the fuzzer now asserts the contract: a rejection must be ErrMintEmailInvalid and must return no value, and an acceptance must be lower-cased, trimmed, and idempotent. 633k executions found 211 new interesting inputs and no failure. TestMintHandler_TheAuditRowDoesNotCarryTheEmail walked only Metadata and only string values, while AuditEntry carries UserID, ClientID, IP, UserAgent, FingerprintHash and DeviceID outside it. Auditing the email as the actor left it green; it now marshals the whole entry, which is the idiom blob_audit_privacy_test.go uses for the same question. Confirmed by mutation: passing result.Email as the audit subject now fails it. Three counts were stale: config.md said the chart exposes "all six" mint settings and it exposes seven, values.yaml said "four settings beside the switch" and there are six, and the invalid-address table named four reasons for five values.
42-v
enabled auto-merge (squash)
August 26, 2026 19:53
42-v
force-pushed
the
feat/mint-email-claim
branch
from
August 26, 2026 19:53
f72dbd9 to
740f415
Compare
Two holes in this branch's own tests, both found by reviewing it rather than by running it, and both the same shape: an assertion that cannot fail for the reason its name gives. The fuzz property never executed. Nothing seeded an email key, there is no testdata corpus for this target, and the fuzzer mutates from the corpus -- so the branch ran zero times while the subject branch beside it ran thousands. Two seeds fix it, and a probe confirms the branch is now entered; 438k executions with 42 new interesting inputs and no failure. The audit-privacy test justified marshalling the whole entry by naming FingerprintHash, which is the one field the marshal cannot see: it is tagged json:"-", so json.Marshal never emits it. Passing the address as the fpHash argument wrote it to audit.audit_log and left the test green. That field is now asserted by hand, and the mutation fails it. The parity claim in mint.go was also not true. ValidateMintSubject has a dedicated target under tests/fuzz, which is the only directory CI runs with -fuzz; FuzzMintRequestJSON runs there as an ordinary test. So in CI this validator is exercised by its seeds rather than by mutation, which is exactly why the seeds not existing mattered. The comment says that now.
Two golangci-lint findings in the parity gate this branch added. The tree-wide run catches both; the package-scoped run I had been using does not reach tests/spec, which is where the gate lives.
…ument The parity gate reads docs/api.md raw, and the liveness meta-gate is right to ask why: a construct found only in a comment would let a gate certify a claim the file does not make. The answer here is that the alternative it offers does not exist for this file. commentFreeSource knows .go, .sql, .yaml, .tpl and .txt, and t.Fatals on anything else rather than guessing, so it cannot be pointed at Markdown at all. The precedent is one entry above: the DPoP gate reads docs/security.md for the same reason. Published prose is also exactly what this gate has to read. The defect it holds was api.md describing the mint response while omitting a field the server sends, and spec.md makes api.md authoritative for response bodies. Nothing can hide in the section either -- it is bounded by Markdown headings, and what it looks for is a fenced json block and a table header, neither of which is comment syntax in any language.
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
Merging this publishes nothing. vault42 releases are cut by pushing the annotated tag.
Why
/mintexists because eleven services hold foreign-key copies of a platform's own user ids. A relying party keyed on those ids can use a token that names only an opaque subject. A relying party keyed on email cannot.BeOn3 is the second kind. Its storage worker derives every avatar's owner key as
GUID(SHA256(userId+email)), and six of its services read the claim. A missing email there does not fail loudly — it silently detaches every object from its owner. This is blocker 1 of 8 for a BeOn3 cutover.Everything about how it works says "assertion", not "fact"
Off by default (
VAULT_MINT_ALLOW_EMAIL). Every other minted claim is a name vault42 issued or an operator allow-listed. An email is a caller-supplied identifier for a subject vault42 has never heard of, so enabling it says the operator trusts this client to assert who its users are — a larger statement than trusting it to name a subject id, and one that should be made rather than inherited.Refused, not stripped.
403 email_not_permittedwhen the setting is off,400 invalid_emailwhen the address is malformed. Two codes because they are two different mistakes: a client pointed at the wrong deployment, versus a bug in the caller. Same call the role allow-list already makes, for the reason it records — a signing oracle that quietly issues something other than what was asked for hides the misconfiguration that produced the request.Normalised before signing. Lower-cased and trimmed. Nothing here looks the address up, but the relying party will look it up in its table, and handing two spellings of one address to two callers is how that table grows two rows for one person.
sanitize.Emailvalidates and stays a validator rather than becoming a normalizer — homoglyphs and bidi controls remain distinct byte strings, the contracttests/attack/unicode_email_test.gopins.Not in the audit row. Audit entries outlive an erasure by design, and this is caller-asserted personal data vault42 never verified and keeps no other record of. Writing it there would create one, in the single table built to survive the subject asking to be forgotten. The row still names subject, client, roles and scopes.
Not on any other token. Set only on the
/mintpath,omitempty, so no login, refresh or client-credentials token grows an email field.docs/api.md's claims table states that a reader must not treat it as proof of address ownership, rather than leaving it to be inferred.Placement detail
The claim is appended at the end of
VaultClaimson purpose: the register carries prose citations by line intointernal/crypto/jwt.go, and appending moves none of them. TheConfigstruct field does move fourteen, whichscripts/register-reanchor.py --applyre-derived.Verification
Gates specifically exercised:
TestTheChartCanSetEverySettingTheServerReadsandTestTheUnexposedSetOnlyShrinks(chart wired, no exemption bought),TestEveryBooleanEnvironmentVariableIsRegistered,TestEveryEmittedErrorCodeIsDocumented,TestNoTokenClaimIsSpelledBothWays, andmintRequestJSONFieldinFuzzMintRequestJSON— which hardcodes the accepted field list and would have failed the momentemailwas accepted without being declared there.New tests cover: opt-in refusal, normalisation into both the result and the claim,
omitemptyholding when no email was asked for, five distinct validator rejections, the two HTTP codes, the response echo, and the audit row not carrying the address.Known follow-up in this PR
The
Go coverage gatewill report a new statement floor, as it did on #65. I have not guessed the number — the gate prints the exact total and the packages that moved, and I will set it from that rather than from an estimate. Coverage of reachable statements is unchanged at 100%.preferred_usernameis deliberately not included. Email is load-bearing for BeOn3; nothing was shown to consumepreferred_username, and doubling the assertion surface for an unproven need is the wrong trade. Filed separately.