Skip to content

feat(mint): let a minted token carry a caller-asserted email - #68

Open
42-v wants to merge 9 commits into
mainfrom
feat/mint-email-claim
Open

feat(mint): let a minted token carry a caller-asserted email#68
42-v wants to merge 9 commits into
mainfrom
feat/mint-email-claim

Conversation

@42-v

@42-v 42-v commented Aug 26, 2026

Copy link
Copy Markdown
Owner
feat(mint): let a minted token carry a caller-asserted email

Merging this publishes nothing. vault42 releases are cut by pushing the annotated tag.

Why

/mint exists 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_permitted when the setting is off, 400 invalid_email when 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.Email validates and stays a validator rather than becoming a normalizer — homoglyphs and bidi controls remain distinct byte strings, 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 subject, client, roles and scopes.

Not on any other token. Set only on the /mint path, 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 VaultClaims on purpose: 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.

Verification

go build ./... ; go vet (service, handler, config, cmd/vault)   clean
go test ./internal/config/ ./internal/crypto/ ./internal/service/  ok
go test ./internal/handler/                                       ok
go test ./tests/spec/                                             ok
go test ./tests/compliance/ -run TestComplianceRegister           ok
scripts/register-reanchor.py --check                              0 would move (after --apply)
gofmt -l internal/ cmd/                                           clean

Gates specifically exercised: TestTheChartCanSetEverySettingTheServerReads and TestTheUnexposedSetOnlyShrinks (chart wired, no exemption bought), TestEveryBooleanEnvironmentVariableIsRegistered, TestEveryEmittedErrorCodeIsDocumented, TestNoTokenClaimIsSpelledBothWays, and mintRequestJSONField in FuzzMintRequestJSON — which hardcodes the accepted field list and would have failed the moment email was accepted without being declared there.

New tests cover: opt-in refusal, normalisation into both the result and the claim, omitempty holding 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 gate will 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_username is deliberately not included. Email is load-bearing for BeOn3; nothing was shown to consume preferred_username, and doubling the assertion surface for an unproven need is the wrong trade. Filed separately.

@42-v
42-v force-pushed the feat/mint-email-claim branch 4 times, most recently from 6a4ba2a to 2c185d4 Compare August 26, 2026 15:11
@42-v 42-v closed this Aug 26, 2026
@42-v 42-v reopened this Aug 26, 2026
42-v added 6 commits August 26, 2026 21:53
/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
42-v enabled auto-merge (squash) August 26, 2026 19:53
@42-v
42-v force-pushed the feat/mint-email-claim branch from f72dbd9 to 740f415 Compare August 26, 2026 19:53
42-v added 3 commits August 26, 2026 22:00
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant