Skip to content

chore(release): v1.17.0 - #484

Merged
Jaro-c merged 13 commits into
mainfrom
develop
Sep 27, 2026
Merged

Jaro-c merged 13 commits into
mainfrom
develop

Conversation

@Jaro-c

@Jaro-c Jaro-c commented Sep 27, 2026

Copy link
Copy Markdown
Member

This release is the second round of the September review: every package got a fresh read, and what it found is fixed here. One signature changes and several inputs that used to pass are refused now, so read the section below before upgrading.

Breaking

Security fixes

Other changes

Signed-off-by: Jaro-c 75870284+Jaro-c@users.noreply.github.com

Moves the `require` in all nine example modules from `v1.16.0` to
`v1.16.1`, which the module proxy has served since 20:11 UTC today. Only
the authcore lines change in `go.mod` and `go.sum`; nothing else was
tidied in.

Each example builds and vets on its own with `GOWORK=off` against the
published version, and `go build`, `go vet` and `go test` pass for all
nine through `examples/go.work`. v1.16.1 changes no API, so no code
changes.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
… tags off main (#461)

The GitHub Release now carries what the release pull request says.
Before this, `release.yml` published `--generate-notes` alone, which is
a list of titles, so v1.15.0 and v1.16.0 went out with no breaking
callout and v1.16.1 with no mention of its behaviour change until I
added them by hand (#460).

**`scripts/release-notes.sh <commit>`** finds the merged pull request
into `main` whose merge commit is `<commit>` and prints its body without
the DCO trailer. It fails, printing nothing, when there is no such pull
request, when the body is empty, or when the pull request is labelled
`breaking` and has no `## Breaking` section. #397 and #450, the two
breaking releases so far, both have one.

**`release.yml`**:
- a new first step refuses a tag whose commit is not on `main` (`git
merge-base --is-ancestor`), which the release contract asks for and
nothing here checked;
- the notes are composed before build and test, and `gh release create`
gets them through `--notes`, with `--generate-notes` appending the list
underneath;
- the job gets `pull-requests: read` for that, and its checkout gets
`persist-credentials: false`, since it publishes through `gh` and never
pushes with git;
- the comment about curated notes in a `CHANGELOG.md` this repository
does not have is gone.

**Tests.** `scripts/release-notes.test.sh` runs the script against a
stubbed `gh` whose fixture is a real `gh pr list` response for #458,
#450 and #397 captured today, with variants that each change one thing.
25 assertions, each refusal paired with an acceptance of the same shape.
It runs as a new `release-notes` job in `ci.yml` on every push and pull
request.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| drop the `## Breaking` check | 3 assertions fail |
| drop the empty-body check | 4 fail |
| stop removing the trailer | 6 fail |
| stop removing carriage returns | 1 fails |
| accept any pull request instead of the one with this merge commit | 11
fail |
| drop `--base main` from the query | 1 fails |
| strip `Signed-off-by` lines anywhere, not just at the end | 1 fails |

**Run against the real repository**, read-only: the two new steps of
`release.yml`, extracted as they are, produced 17 lines of notes for
`v1.16.1` and 45 for `v1.16.0`, and a local tag on `e3ae07f` (on
`develop`, not on `main`) was refused with `zz-probe-offmain points at
e3ae07f…, which is not on main`.

**Not verified until the next tag**, and they fail differently:
- that `pull-requests: read` is enough for the job token. If not,
"Compose release notes" fails before anything is published;
- that `--notes` plus `--generate-notes` puts the notes above the list,
which is how `gh release create --help` documents it. If not, the
release is published with the notes in the wrong place, and I fix the
page with `gh release edit`, which works on these immutable releases
(done three times today).

shellcheck 0.10.0 and actionlint are clean on every changed file.

Closes #460

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
Tests for two controls that could be deleted with the suite green, and
for one error path nothing exercised (#462). No production behaviour
changes; the only non-test edit is a comment.

**`internal/keymanager`** (`readcap_stream_test.go`):
- `readCapped` on `/dev/zero`, which Stat reports as zero bytes, must be
refused by the read-time cap and not by the Stat check. Paired with
regular files at the cap (read whole) and one byte past it (the Stat
refusal).
- End to end, a provisioned set whose `refresh_secret.key` is replaced
by a link to `/dev/zero`: `Load` and `New` both refuse it and the
private key is untouched. The refusal comes from the regular-file check
in `fsguard.go`, before any read, and the test asserts that one.
- `New` and `Load` on a `KeysDir` below a regular file report `inspect
key directory "<dir>"` and create nothing.
- Where `/dev/zero` does not exist the two stream tests skip and say
what was not exercised.

**`auth/apikey`**: `validateConfig(Config{})` is refused with "prefix
must not be empty", paired with a one-character prefix that passes. A
comment on the check says why it stays although `New` never reaches it:
without it, an empty prefix passes every other rule.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| disable the read-time cap in `readCapped` |
`TestReadCapped_refusesAStreamLongerThanTheCap` fails |
| disable the regular-file check in `fsguard.go` | the end-to-end test
fails, and its message shows the read-time cap caught the device instead
|
| skip the inspect error in `New` |
`TestKeysDirBelowARegularFileIsReported` fails |
| disable the empty-prefix check |
`TestValidateConfig_refusesAnEmptyPrefix` fails |

The second row is the defence in depth working: with the first barrier
gone, the second still refused.

`internal/keymanager` goes from 90.5% to 91.0%, `auth/apikey` from 93.6%
to 94.9%. `gofmt`, `go vet ./...`, `go test -race -count=1 ./...` and
gosec are clean.

Closes #462

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…again (#465)

Closes the two gaps in #464.

- **`azp`**: after the exact-audience check, a present `azp` must equal
the client id. This restores what #426 removed.
- **The signing key's `issuer`**: the JWK decoder keeps the `issuer`
member, the cache hands the selected key back with it (`candidateFor`,
with `key` as a thin wrapper for existing callers), and `checkKeyIssuer`
requires the token's `iss` to equal it. A `{tenantid}` template is
completed from the token's `tid`, and a template key with no `tid` is
refused. A key without the member (every Google and Discord key in the
captured JWKS) is unrestricted, as before.

I checked the captured provider documents before relying on this: every
key in Microsoft's common JWKS carries the v2 form
(`https://login.microsoftonline.com/<tenant or {tenantid}>/v2.0`), which
is the only issuer form the Microsoft preset and
`AzureMultiTenantIssuer` accept, so no token that verified legitimately
is refused now.

`VerifyIDToken`'s doc comment, the "What it guarantees" list in
`docs/oauth.md` and the `ErrIDTokenInvalid` row in `docs/errors.md` say
so.

Tests in `idtoken_binding_test.go`, each refusal paired with the same
token shape that verifies:

- `azp` naming another client is refused; `azp` equal to the client id
verifies;
- a key pinned to the consumer tenant refuses a token for another tenant
and verifies one for its own;
- a `{tenantid}` key verifies when `iss` and `tid` name the same tenant,
refuses when they differ, and refuses a token without `tid`;
- a key without `issuer` verifies under the fixed issuer check.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| skip the `azp` comparison | `refusesAnotherClientsAZP` fails |
| make `checkKeyIssuer` return early | the tenant and template tests
fail |
| stop completing the template | the template test fails |
| stop requiring `tid` for a template key | the template test fails on
the missing-`tid` case |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...` and gosec are
clean; `auth/oauth` is at 92.9%.

Closes #464

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…d harden the JWKS outage paths (#467)

Fixes the six defects in #466, one change each:

1. `UserInfo` refuses a 200 whose object carries `error`, and an empty
object, with `ErrUserInfo`.
2. Basic credentials are form-encoded (`url.QueryEscape`) before
`SetBasicAuth`, per RFC 6749 section 2.3.1. The usual secret shape
(letters, digits, `-._~`) is unchanged by the encoding, and a test pins
that.
3. `applyDefaults` copies `Provider.AuthMethods`, as it already did
`Scopes`.
4. `VerifyIDToken` refuses any byte outside the compact JWS alphabet
before parsing and parses with `WithStrictDecoding`, so a token has one
spelling.
5. A stale known kid gets the same one-minute cooldown after a failed
refresh as an unknown one, returning `ErrJWKSStale` (which still matches
`ErrJWKS`) without fetching. The acceptance half of
`TestJWKS_failsClosedAfterExpiry` now steps past the cooldown before
expecting a successful refresh.
6. A caller whose joined fetch ended in `context.Canceled` while its own
context is live runs the fetch again. The retry covers the case where
the caller that started the fetch has since gone away; it does not
change what happens on a timeout.

The `Exchange` and `Provider.AuthMethods` comments now say that Post is
sent when nothing is advertised, which is what the code does.

Tests, each refusal paired with an acceptance of the same shape:

- `lenient_paths_test.go`: error object and `{}` refused, a real profile
returned; Basic credentials read back through `url.QueryUnescape` by the
test server for a hostile and an ordinary secret; the caller's slice
changed after `New` without effect; a newline and flipped unused bits in
the signature refused, the token as signed accepted.
- `jwks_outage_internal_test.go`: five lookups inside the cooldown make
no fetch, and one past it fetches exactly once and serves the key; and,
under `testing/synctest` so the ordering is deterministic, a caller
parked on a fetch whose starter is cancelled gets the key after one
retry. Both ran three times under `-race`.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| accept the error object / the empty object | the UserInfo test fails
(both) |
| send Basic credentials raw | `formEncodesBasicCredentials` fails |
| drop the `AuthMethods` copy | `copiesTheAdvertisedAuthMethods` fails |
| drop the alphabet check / drop strict decoding |
`refusesASecondSpellingOfTheToken` fails (both) |
| drop the stale-kid cooldown | `staleKidWaitsOutTheCooldown…` fails |
| drop the retry | `joinedCallerRetries…` fails |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec and the
nine example builds are clean; `auth/oauth` is at 93.1%.

Closes #466

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
… encoding (#469)

`New` now refuses a `PreviousPublicKeys` entry that anyone could forge
signatures under (#468).

`checkVerificationKey` (`auth/jwt/pubkey.go`), standard library only:
- the encoded `y` must be below `2^255 - 19` (canonical);
- `y = 1` is the identity and is refused;
- any other point is mapped to its Montgomery `u = (1+y)/(1-y)` and run
through `crypto/ecdh` X25519, which clamps the scalar to a multiple of 8
and returns an error on the all-zero output. That is exactly the case of
a point of order 1, 2, 4 or 8.

I chose this over a hard-coded blocklist so the check does not rest on
constants copied from memory, and over `filippo.io/edwards25519` to
avoid a new dependency. The refusal is `ErrInvalidConfig` with `previous
public key <i> cannot be trusted: <reason>`, and the
`PreviousPublicKeys` doc comment says so.

**Tests** (`pubkey_test.go`). The oracle is the forgery itself,
independent of how the check decides: a key is dangerous when
`ed25519.Verify` accepts `R = identity, S = 0` for one of 64 messages.
- Refused: all zero, all zero with the sign bit, the identity, `y =
p-1`, the three non-canonical encodings `p`, `p+1`, `2^255-1`, and both
order-8 points. The test asserts the oracle really forges under each
canonical small-order key, the two order-8 encodings included, so a
wrong constant would fail the test instead of passing it.
- Accepted: 200 generated keys, with the oracle unable to forge under
them.
- End to end: `New` with the all-zero key is `ErrInvalidConfig`; with a
real previous key it succeeds.

**Sabotage**, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| skip the check in `validateConfig` |
`TestNew_refusesAForgeablePreviousKey` fails |
| skip the canonical check | the three non-canonical cases fail |
| skip the identity check | the identity case fails, and the code panics
on it (`ModInverse(0)` is nil) |
| skip the small-order check | the small-order cases and the `New` test
fail |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...` and gosec are
clean; `auth/jwt` is at 94.1%.

Closes #468

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
… to regenerate a recorded set (#471)

Fixes #470 in three commits.

**Key material behind one more pointer.** `KeyManager` now holds
`material *secretMaterial` instead of the three slices. fmt reaches a
`*KeyManager` inside an unexported field without calling its `Format`,
and on a verb pointers do not support it dereferences exactly one level.
That level now ends at an address. The redaction methods are unchanged.
I did not add `Format` methods to `AuthCore` and the stores, because
they would not cover a caller's own struct that holds a `Config`.

`format_leak_test.go` formats the `*AuthCore` and `AuthCore` on an
in-memory store, the `Config` carrying the `KeyStore`, `ac.Config()`,
the `KeyStore` itself and an `*AuthCore` on the disk store, with 15
verbs. It searches each output for the private key and the refresh
secret in decimal, both hex cases and the `%#v` form. Run against the
code before this change, it reports 84 leaks; with it, none.

**A recorded key set is not regenerated.** When the key files are all
gone and `metadata.json` is present (it is written only after a set is
published), `New` refuses. The message names the recorded key id, says
to restore from a backup, and says that deleting `metadata.json` is the
deliberate way to start over. The message does not tell anyone to delete
key files. The test checks the refusal, that no key file was created and
the marker is byte-identical, and that deleting the marker then yields a
new set.

**`metadata.json` must be a regular file.** `readMetadata` stats the
path and refuses anything else before opening it, so a FIFO no longer
blocks `New` or `Load`. The test (unix only, since it needs `mkfifo`)
runs both with a 10 s guard.

**The committed binary is removed**, and `/authcore-keygen` and
`/cmd/authcore-keygen/authcore-keygen` are ignored. Rebuilding it at the
root now leaves `git status` clean. The three tagged releases that
contain it cannot change.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| let an empty set with a marker regenerate |
`TestNewRefusesToRegenerateWhenMetadataRecordsAKeySet` fails |
| drop the regular-file check on the marker | the FIFO test fails for
`New` and `Load` after its 10 s guard |
| the formatting fix | checked against the previous code instead: 84
leaks found. A sabotage that flattens the struct does not compile, so it
proves nothing |

Each commit builds on its own. `gofmt`, `go vet ./...`, `go test -race
-count=1 ./...` and gosec are clean. `internal/keymanager` is at 90.8%
and the root package at 96.1%.

Closes #470

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…cept one spelling per token (#473)

Fixes #472 in three commits, each passing its own tests.

**jwt: revocation, leeway, zero values, typed-nil denylist**
- `RotateTokens` consults the `Denylist` after verifying the refresh
token and returns `ErrTokenRevoked` for a revoked session.
`RotateTokensContext` is new (additive) and passes the caller's context
to the lookup; `RotateTokens` uses the same 5-second default as
`VerifyAccessToken`. A store error fails closed.
- `docs/jwt.md` now sizes the entry to outlive the session (until the
newest refresh token's expiry plus leeway) and says to delete the stored
refresh hash in the same step. Rotation is refused while the entry
exists, so no later refresh token can appear.
- `ClockSkewLeeway` is capped at 5 minutes; a larger value is
`ErrInvalidConfig`. **A configuration with a larger leeway now fails at
`New`.** Five minutes is the usual clock-skew tolerance, and anything
past it extends `exp` beyond what the TTL ceilings allow.
- `CreateTokens`, `VerifyAccessToken[Context]` and
`RotateTokens[Context]` return `ErrNotInitialised` on a zero value or a
nil pointer instead of panicking.
- `New` refuses a `Denylist` holding a nil pointer.
- The `RefreshTokenExpiresAt` wording no longer claims it is when the
user must log in again; under rotation each refresh issues a new one.

**jwt and field: one spelling per token or ciphertext**
- Both verifiers share one parser option list, `parserOptions`, which
adds `WithStrictDecoding`, so the access and refresh paths cannot drift
apart again. `checkTokenString` refuses any byte outside the compact JWS
alphabet before parsing, with `ErrTokenMalformed`.
- `field.Decrypt` refuses a ciphertext that does not re-encode to
itself.

**apikey**: `ParseID` returns `ErrNotInitialised` on a zero value or a
nil pointer.

Tests pair each refusal with the case that still works: rotation of a
revoked session, of an active one, and with a failing store; the leeway
exactly at and 1 ns past the ceiling; a typed-nil denylist; every method
on a zero value and a nil pointer; a line break and a flipped unused bit
in access tokens, refresh tokens and ciphertexts, next to the originals;
a zero-value `ParseID`.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| skip the denylist in `RotateTokensContext` | `refusesARevokedSession`
fails |
| drop the leeway ceiling | `boundsTheClockSkewLeeway` fails |
| drop the typed-nil check | `refusesATypedNilDenylist` fails |
| drop the `CreateTokens` guard | the zero-value test fails (panics on
the nil pointer) |
| drop the alphabet check / drop strict decoding |
`IssuedTokensHaveOneSpelling` fails (each) |
| drop the canonical check in `Decrypt` |
`refusesASecondSpellingOfTheCiphertext` fails |
| drop the `ParseID` guard | `ParseID_zeroValueRefuses` fails (panics) |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec and the
nine example builds are clean. `auth/jwt` 94.5%, `auth/field` 92.9%,
`auth/apikey` 95.0%.

Closes #472

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…ix the otpauth label (#475)

Fixes #474 in three commits, one per package group, each passing the
tests of all four packages.

**password, username**: `New` refuses a nil provider and a nil logger,
and `password.New` refuses more than one `Config`, all as
`ErrInvalidConfig`. These are the guards #425 added to the other four
modules.

**totp**
- `New` refuses a nil provider, logger or `Keys()`, a second `Config`,
and a refresh secret that is not 32 bytes.
- `New` copies `SkewSteps` before validating it, so the caller's int no
longer controls a built module.
- The otpauth label is written as text and escaped once; a colon inside
the issuer or the account is escaped too, so the one literal colon stays
the separator. `TestEnroll_URI_RoundTrips` and
`TestEnroll_URI_AwkwardAccountName` pinned the double escaping and now
assert the raw label and its split.
- `decodeSecret` accepts only the spelling `Enroll` hands out.
- The log line prints the skew value, and the `New` and `applyDefaults`
comments describe the pointer semantics.
- `docs/totp.md`: recovery codes are consumed by a conditional `DELETE`
whose affected-row count decides success. The page now says why the
read-modify-write of the whole list is wrong, and the footguns list
repeats it.

**email**
- `New`/`NewWithConfig` refuse a nil provider or logger, with a new
`email.ErrInvalidConfig` (added to `docs/errors.md`).
- An all-digit top-level label is refused.
- `VerifyDomain` refuses a domain past DNS size limits with
`ErrInvalidEmail` before touching the cache or the resolver.
- The shared MX lookup runs under `context.WithoutCancel(ctx)` bounded
by a 10-second module timeout, so the caller that started it can give up
without deciding the answer for the others. Each caller's own context
still bounds its wait, and the doc comment says so.

Tests, each refusal paired with what is accepted:
- constructor guards in all four packages;
- `SkewSteps` written to 1,000,000 after `New` while an hour-old code
stays refused and the current one verifies;
- line breaks in a TOTP secret;
- `127.0.0.1` and `1.2` refused, `123.example.com` and `example.co1`
accepted;
- oversized domains refused with the resolver never called and nothing
cached;
- a starter cancelled mid-lookup while a joined caller still gets
`ErrDomainNoMX`. This one runs under `testing/synctest`, so the ordering
is deterministic.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| drop the nil-provider guard (password, username, email) | each
constructor test fails, panicking |
| drop the extra-config refusal (password) | the constructor test fails
|
| drop the `SkewSteps` copy | `copiesSkewSteps` fails |
| drop the secret-length check (totp) | the constructor test fails |
| drop the canonical-secret check | `refusesLineBreaks` fails |
| build the URI through `url.URL` again | both URI tests fail |
| drop the all-digit check / the size check | their tests fail |
| run the lookup on the caller's context | the cancellation test fails |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec and the
nine example builds are clean. Coverage: password 95.7%, totp 95.1%,
email 94.4%, username 94.8%.

Closes #474

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…d pin the unpinned controls (#477)

Closes #476 in three commits, each building and passing on its own.

**keymanager**
- `decodePEMBlock` is the one parser behind both key files and
`FromPEM`: the input must start with a PEM block, the block must be
labelled `PRIVATE KEY` or `PUBLIC KEY` as expected, carry no headers,
and be the only thing in the input apart from whitespace. The
refresh-secret error no longer wraps the hex package's error, which
quotes a byte of the file. **A key file with text before or after its
block, or with headers, is refused at startup now.** Files written by
authcore, by `openssl genpkey`/`pkey -pubout`, and by `authcore-keygen`
are unaffected.
- The permission warning covers write bits (mask `0o066`), so a secret
file others can replace is reported, and `Load` warns when `KeysDir` is
group- or world-writable without the sticky bit (a Kubernetes Secret
mount is `1777` and is not reported). `New` still tightens the directory
instead.
- A parent directory that cannot be opened for its fsync (search but no
read permission) is a warning on the first run, as it already was on the
load path; the test that pinned the refusal is rewritten to the new
contract.
- The two comments claiming `exists` makes callers "fail closed rather
than generating" now say what it feeds.

**KeyID**: `New` refuses a `KeyStore` whose `Keys` report an empty
`KeyID`, and `jwt.New` refuses a `PreviousPublicKeys` entry whose id
equals the current key's, with `ErrInvalidConfig`, instead of letting it
replace the current key in the verification set. The `Keys.KeyID`
contract and `docs/key-management.md` say so. I did not require a custom
store to reproduce the built-in derivation: it is an internal detail,
and a store with its own stable ids has worked and keeps working.

**docs**: `docs/faq.md` no longer tells an operator to delete
`.authcore`; `docs/containers.md` describes concurrent first start as
measured today rather than as "being reworked".

**Tests**
- `mode_table_test.go`: {`New`, `Load`} × {private key, refresh secret}
× {0640, 0604, 0602, 0620, 0600, 0400}, exactly one warning naming the
file for the four open modes and none otherwise; `Load` on a 0777 and
0733 directory warns, on 1777 (sticky), 0700 and 0750 does not, and the
mode is left as found.
- `review_round2_internal_test.go`: `New` on a 0555 directory leaves it
at 0500 and 0750 at 0700; recovery with an unrelated staging directory
that sorts first still completes from the one whose private key matches;
seven non-canonical PEM inputs refused with the reason named and the
canonical pair (with surrounding whitespace) accepted; the not-hex error
names no byte of the file and a hex file of the same length loads.
- `TestNewWarnsWhenTheParentCannotBeSynced`,
`TestNew_refusesAnEmptyKeyID` (with a custom non-empty id accepted),
`TestNew_refusesAPreviousKeyWithTheCurrentKeyID` (with a different key
registered).

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| drop the secret warning on `New` / the private-key warning on `Load` |
the mode table fails on those cells |
| mask group only / read only | the 0604 and 0602/0620 cells fail |
| `Chmod(dir, dirMode)` instead of `mode&dirMode` | the tighten test
fails |
| accept any staging directory (`\|\| true`) | the recovery test fails |
| drop the type, header, trailing-content or prefix check | the matching
PEM cases fail |
| wrap the hex error again | the no-echo test fails |
| refuse the parent sync again / drop the directory warning | their
tests fail |
| accept an empty `KeyID` / a previous key with the current id | their
tests fail |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec and the
nine example builds are clean. `internal/keymanager` is at 90.3%, the
root package at 96.2%, `auth/jwt` at 94.5%. The keymanager figure sits
close to the floor because the strict parser and the directory warning
add branches; the untested ones are the Windows returns and the
non-permission parent-sync failure, which has no fault seam.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…cal email local parts (#479)

Closes #478 in four commits, one per package, each building and passing
on its own.

**password**
- `isPrintable` also refuses U+2800 BRAILLE PATTERN BLANK and the code
points in Unicode's `Other_Default_Ignorable_Code_Point` (the Hangul
fillers, U+034F and the like), which `unicode.IsPrint` admits although
they render as nothing. Variation selectors stay accepted: they modify a
visible base. The package doc and `docs/password.md` say what is
refused. **A new password holding one of these is refused now**;
existing hashes verify as before, since `Verify` applies no policy.
- Tests pin what the review found unpinned: `Hash` normalises to NFC
(NFD register, NFD and NFC sign-in); all four `*bool` policy snapshots,
by flipping the caller's variable after `New`; the `Memory` and
`Iterations` ceilings at `New`, with the value at the ceiling accepted;
and a stored hash with a bare label (`$v$`, `$m$`, ...) refused with
`ErrInvalidHash` rather than panicking.

**totp**
- Every method refuses on a zero value or a nil pointer: `Enroll`,
`VerifyStep` and `Verify` with `ErrNotInitialised`, `VerifyRecoveryCode`
with `(0, false)`, and **`HashRecoveryCode` now returns `(string,
error)`** so it can report it, the change #425 made to `apikey.Hash` for
the same reason. The sentinel is in `docs/errors.md`.
- `FuzzVerify` has an oracle: RFC 4226 written apart from the package
(HMAC-SHA1, dynamic truncation, six digits) decides which codes the
fixed clock's window accepts, and `VerifyStep` and `Verify` (with an
in-memory recorder) must agree exactly. Seeds cover every step of the
window and one outside it on each side.

**email**
- The local part has one canonical spelling: NFC, then lowercase.
Control (Cc, C1 included), format, line and paragraph separator and
default-ignorable characters in it are refused, and so is a non-ASCII
letter whose lowercase is ASCII (U+0130 to `i`). U+212A KELVIN SIGN is
canonically equivalent to K and becomes `k` through NFC, which the test
states. **`josé@` now stores as `josé@`**, so a row stored from
decomposed input before this change no longer matches; I know of none.
- `evictExpired`, which nothing in production called, is gone; the
eviction inside `store` is tested with a full cache of expired entries,
and an empty MX answer with no error is pinned as `ErrDomainNoMX`. The
`maxCacheSize` comment no longer describes a background goroutine.

**username**: the fuzz target asserts the `[a-z0-9_-]` set, the length
bounds and the reserved list on every accepted value, with seeds that
put a disallowed byte between allowed ones, since the start and end
rules refuse the old seeds before `isAllowed` sees them.

Sabotage, each edit confirmed to change the file:

| Sabotage | Result |
|---|---|
| drop NFC in `Hash` | `TestHash_normalisesTheInputItHashes` fails |
| drop any one of the three policy snapshots | its subtest fails |
| drop the memory or the iterations ceiling |
`refusesWorkFactorsPastTheCeilings` fails |
| drop the label prefix check | the bare-label test fails, with the old
panic |
| drop the default-ignorable or the braille refusal | the
blank-rendering test fails |
| drop NFC / the control refusal / the ASCII-fold refusal in the local
part | the matching local-part test fails |
| drop the eviction / the empty-answer branch | their tests fail |
| drop the `Enroll`, `HashRecoveryCode` or `VerifyRecoveryCode` guard |
the zero-value test fails |
| make `stepMatches` accept every code | `FuzzVerify` fails on its seeds
|
| make `isAllowed` accept every byte | `FuzzValidateAndNormalize` fails
on the new seeds |

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec and the
nine example builds are clean. Coverage: password 98.8%, totp 95.4%,
email 95.9%, username 94.8%.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…tion and blind-index guides (#481)

Closes #480 in three commits: two are tests only, one is documentation
and a package doc comment. No production behaviour changes.

**Guides**
- `docs/secure-login.md` section 5 replaces the refresh hash with the
conditional `UPDATE ... WHERE id = $2 AND refresh_hash = $3` and a
row-count check, the recipe `docs/jwt.md` step 5 got in #427, and says
that reuse detection depends on it.
- The `auth/field` package doc's read path recomputes the index of the
decrypted value and compares it with the one looked up; the sentence
claiming an index hit proves the plaintext is gone.
- `docs/configuration.md` describes the credential binding as the
length-prefixed construction the code uses; `docs/errors.md` gains the
`jwt.ErrTokenOversized` and `jwt.ErrNotInitialised` rows;
`docs/key-management.md` says which modules share one HMAC key and why
that waits for a release that can change stored hashes.

**jwt tests**
- The claim checks: a token without `exp` and one issued a day in the
future are refused on the access path (the signer fixture now carries a
UUIDv7 `jti`, since #443's shape check refused the old `"jti"` first and
left these tests green with the option deleted) and on the refresh path;
each is paired with the same token accepted. `mapJWTError` collapses the
reason into `ErrTokenInvalid`, so the acceptance twin is what proves
which check fired.
- The leeway through `RotateTokens` (20 s past `exp` accepted with a 30
s leeway, 40 s refused); the refresh-token issuance cap with a 5806-byte
issuer refused and a 5600-byte one issuing and rotating;
`validateConfig` refusing an empty audience directly, which `New` never
reaches; the zero-value `VerifyRefreshTokenHash` guard against the hash
an empty key produces, which the old fixture (`"any-hash"`) never
reached.
- `FuzzIsUUIDv7` compares against RFC 9562 written as a regular
expression apart from the code, with seeds for a moved dash, hex digits
where the dashes go, a non-hex byte and a short input;
`FuzzVerifyAccessToken` accepts only the issued access token and
requires every refusal to be a documented sentinel.

**apikey, credential, field tests**
- Known answers for every stored format, with the expected HMAC and HKDF
values computed outside Go under a fixed secret: the apikey hash, the
refresh-token hash, the credential hash (length-prefixed purpose,
subject, token) and the blind index; and a ciphertext produced today
that must keep decrypting, which pins the encryption label, the AAD and
the nonce placement.
- `FuzzParseID` seeds that reach the lowercase-hex rule and the prefix
check; a zero-value `apikey.Hash` refused.

Sabotage, each edit confirmed to change the file and to compile:

| Sabotage | Result |
|---|---|
| drop `WithExpirationRequired` / `WithIssuedAt` from `parserOptions` |
the access and refresh tests for that claim fail |
| ignore the leeway | both leeway tests fail |
| drop the zero-value guard in `VerifyRefreshTokenHash` / in
`apikey.Hash` | their tests fail |
| drop the dash, variant or hex check in `isUUIDv7` | `FuzzIsUUIDv7`
fails on a seed |
| widen `isHex` to A-F / drop the prefix check in `ParseID` |
`FuzzParseID` fails on a seed |
| accept a refresh token in `VerifyAccessToken` |
`FuzzVerifyAccessToken` fails on the refresh seed |
| drop the empty-audience check |
`TestValidateConfig_refusesAnEmptyAudience` fails |
| change the blind-index label / drop the AAD / swap the credential
fields / change the apikey or refresh hash input | the matching
known-answer test fails |

The refresh-token issuance cap has a test; its sabotage was not run
separately because the shared verifier refuses the oversized token on
rotation anyway, so the test fails under it through that path.

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec and the
nine example builds are clean. Coverage: jwt 95.9%, apikey 96.2%,
credential 98.4%, field 92.9%.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
…ent the multi-tenant client (#483)

Closes #482 in three commits. Tests and docs only; the one production
edit is the doc comment above `oauth.Microsoft`.

**Tests** (one file per control, except where an existing table already
covered the neighbours):
- `entropy_test.go`: state, nonce and verifier each decode to 32 bytes
and are 43 characters.
- `idtoken_time_claims_test.go`: no `exp` is refused with "exp claim is
required", `iat` an hour ahead with "token used before issued", next to
a token that verifies.
- `presets_fixture_test.go`: three prefix and suffix variants of a
tenant issuer are refused by `AzureMultiTenantIssuer`, the bare tenant
issuer is accepted.
- `redirect_internal_test.go`: `:8443` on the same host is cross-origin,
`:443` is the same origin.
- `exchange_non2xx_test.go`: a 500 with a token-shaped body fails on the
status, the same body at 200 succeeds.
- `discover_https_test.go`: a plaintext issuer is refused with a
transport that counts zero calls; an `http://` `jwks_uri` in the
document is refused and the `https://` one is returned.
- `response_caps_test.go`, `response_caps_jwks_internal_test.go`: a body
of exactly 1 MiB is read, one byte more fails with `ErrExchange`,
`ErrJWKS`, `ErrDiscovery` and `ErrUserInfo`.
- `oauth_url_presets_test.go`: the auth URL carries a stale value for
all eight generated parameters, and each comes back once with the fresh
value.
- `oauth_more_test.go`: `UserInfo` on an OIDC client asserts
`ErrNoUserInfo`, not any error.
- `issuer_predicate_test.go`: a predicate that approves everything still
refuses an empty `iss`.
- `jwks_fuzz_test.go`: an oracle on every accepted key and a real Google
RSA seed (details in the commit).

**Docs**: `docs/oauth.md` shows the hand-built multi-tenant Azure
`Provider` and says why neither the preset nor `Discover` on the alias
can build one; the setup snippet handles every error. `docs/errors.md`
gains the `oauth.ErrJWKSStale` row.
`TestDocumentedMultiTenantProviderIsAccepted` builds the documented
`Config`.

Sabotage, each edit confirmed to change the file, `go test
./auth/oauth/`:

| Sabotage | Result |
|---|---|
| `randomTokenBytes = 1` | `TestAuthCodeURL_tokensCarry32RandomBytes`
fails |
| drop `WithExpirationRequired` / `WithIssuedAt` | the matching
`timeClaimsEnforced` subtest fails |
| drop `^` / `$` from the Azure pattern |
`TestPreset_AzureMultiTenantIssuer` fails (each) |
| `sameOrigin` ignores the port |
`TestSafeRedirect/same_host,_other_port` fails |
| skip the Exchange status check |
`refusesNon2xxEvenWithSuccessBody/rejection` fails |
| skip the Discover issuer / endpoint https check | its test fails
(each) |
| raise each of the four read caps | its `OverCap` test fails (each) |
| `q.Add` for `code_challenge_method` |
`dedupsPreExistingQueryParameters` fails |
| UserInfo returns another error without a URL |
`TestUserInfo_noURLConfigured` fails |
| drop `iss == ""` before the predicate |
`issuerPredicateRejectsEmptyIss/empty_iss_is_refused` fails |
| RSA floor 2048 to 16 bits | `FuzzParseJWK` fails on seed #3, also in a
plain `go test` run |
| skip the EC on-curve check | `go test -fuzz=FuzzParseJWK` fails within
a second |

My first sabotage of the Exchange status check was `false && a || b`,
which still refused a 500; rewritten with parentheses before reading the
result.

`gofmt`, `go vet ./...`, `go test -race -count=1 ./...`, gosec,
staticcheck on the touched files and the nine example builds are clean.
`auth/oauth` is at 93.5%.

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>

---------

Signed-off-by: Jaro-c <75870284+Jaro-c@users.noreply.github.com>
@Jaro-c Jaro-c added type:security Security-relevant change type:chore Maintenance with no product impact prio:P1 High priority status:review In review effort:S Small breaking Backwards-incompatible change labels Sep 27, 2026
@Jaro-c
Jaro-c merged commit eb8d439 into main Sep 27, 2026
38 checks passed
@Jaro-c Jaro-c removed the status:review In review label Sep 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Backwards-incompatible change effort:S Small prio:P1 High priority type:chore Maintenance with no product impact type:security Security-relevant change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant