Files
auth/doc/security-audit.md
T
2026-09-26 20:42:42 -04:00

11 KiB
Raw Blame History

Security audit and integration notes

Audit date: 2026-09-23. Baseline: e4eb41658be8b1d4f46cb695837ebfe564cd2f25. Scope: all production code and tests in lixenwraith/auth, plus current auth call sites in chess and planned use in vi-fighter/logwisp. This is a source audit with regression, race, and bounded fuzz testing, not a formal protocol proof or a production penetration test.

Findings and corrections

Area Finding Correction
SCRAM replay Verification cleared its atomic claim before deleting the handshake. A concurrent request could claim and successfully verify the same proof again. Retain the claim through deletion; regression requires exactly one success under concurrent replay.
SCRAM client Challenges did not need an active handshake or a nonce that extends the client's nonce. Restart retained old authentication material; final messages were reusable. Explicit ordered states, nonce binding, one-use challenges/finals, terminal errors, and reset on restart. Recheck exported password length immediately before the KDF.
KDF downgrade A hostile server could choose a tiny but valid KDF and collect a cheaply guessable client proof. Clients default to a minimum of 64 MiB/3 iterations; an explicit local policy option supports trusted alternate profiles.
Resource exhaustion Username/nonce/salt/proof inputs could allocate or retain unbounded data. Direct derivation and hash options could request unbounded Argon2 work; separate maxima allowed costly combinations. Pre-decode byte limits, credential validation, common KDF limits at every entry point, and a combined memory×iterations ceiling. Retain the existing handshake-count cap and timeout.
Credential ownership Server retained caller-owned credential pointers/slices without validation. Mutation could change challenges or keys, or race authentication. There was no account-removal API. Validate and deep-copy credentials; add Credential.Validate, error-returning AddCredential, and RemoveCredential. Replacement/removal invalidates pending handshakes; successful final verification checks the active credential.
Account enumeration Decoy salts changed on restart/replica boundaries. Last-added credential determined the unknown-user profile, allowing mixed profiles to disclose existence. Add a constructor with a persistent shared decoy key; enforce one profile per server. Perform decoy HMAC work on known-user requests too. Document residual timing/application signals and load-before-serving requirements.
PHC migration The common 32-byte digest branch admitted short salts that SCRAM import rejected. Validate SCRAM salt length on both migration paths; reject the record consistently. Existing PHC verification remains available for legacy salts.
JWT policy Signed tokens without a subject were accepted; future issued-at values were ignored. Unbounded token parsing and noncanonical signature encodings were accepted. Require a nonempty subject and expiry; check issued-at; bound tokens; reject CR/LF, nonzero base64 pad bits, and unsupported critical headers. Keep algorithm pinning and configured issuer/audience checks.
Key/config ownership HMAC secrets, RSA keys, and audience slices were borrowed. Invalid/weak RSA keys were not checked at construction. Copy constructor inputs; validate RSA private material/public parameters and require at least 2048 bits. Accept PKCS#1 public PEM as well as PKIX.
Opaque tokens / HTTP The allowlist could authenticate an explicitly stored empty token and its zero value panicked on insertion. Bearer parsing accepted embedded whitespace and required case-sensitive schemes. Reject empty/oversized tokens, support the zero value, and enforce RFC 6750 Bearer syntax. Remove the unused password-header parser, its errors/tests/docs, and the generic scheme extractor.

Canonical base64 checks also cover PHC records and imported SCRAM credentials. Import accepts common JSON, YAML, and TOML numeric representations (float64, json.Number, int, int64, unsigned integers) with exact range checks. Explicit regressions reject NaN, infinities, fractions, negatives, and overflow. Transient key buffers are cleared where practical; this is not a guarantee of memory erasure.

Compatibility and rollout

Chess at e3e465c9a2a81e4f5904a0e646c9812caa071c31 uses HashPassword, VerifyPassword, ValidatePHCHashFormat, GenerateHS256Token, and ValidateHS256Token. These signatures and JWT extra structure remain intact. The one-off validator is a compatibility adapter to the common hardened parser; it retains the existing five-minute leeway and lack of issuer/audience constraints. Existing well-formed chess tokens remain valid. Its service tests and CLI package were checked against this checkout using a temporary module replacement; no changes to chess are included in this PR.

The inspected vi-fighter (6fd727f848ac05ff6b76b85c8152aad995d9426e) and logwisp (72c0a43c9f2ba017bf2fd62319077b3655ee4514) revisions do not yet import auth. Their future deployment integration is outside this package PR.

SCRAM message JSON fields and the valid transcript encoding stay unchanged. NewScramClient accepts optional local minimum-cost policy options; ordinary two-argument calls retain their syntax, with stronger default challenge policy. AddCredential now returns an error; callers should check it. Calls used as statements still compile, but interfaces requiring the old no-result signature must be updated (none occur in the inspected consumers). Intentionally rejected inputs include malformed identities/nonces, mixed SCRAM profiles, noncanonical base64, oversized tokens, and KDF parameters above the new budget. Short-salt PHC records must be re-enrolled before SCRAM migration. A server's profile remains fixed after removing its last credential to keep decoy responses stable.

Follow-up: legacy HS256 adapter removal (v0.5.0, 2026-09-26)

Chess was the only consumer of GenerateHS256Token and ValidateHS256Token. It now constructs a scoped NewJWT manager (issuer chess-server, audience chess-api, lifetime equal to its session TTL, zero leeway) and parses headers with ParseBearerToken. The adapters, their shared unscoped parser, their tests, and the FuzzValidateHS256Token target were removed; FuzzValidateToken now fuzzes a scoped manager. The unused ErrSaltGenerationFailed was also removed: crypto/rand.Read cannot return an error on supported Go versions, so no path produced it. HashPassword, VerifyPassword, and ValidatePHCHashFormat are general password APIs and remain unchanged.

Removing the adapters is a breaking API change for any out-of-tree caller. The five-minute default leeway of NewJWT itself is unchanged for compatibility; services should still set WithLeeway explicitly.

Integration boundaries

  • Transport: this Argon2/JSON protocol is not standard SASL SCRAM-SHA-256 and has no channel binding. Authenticated TLS/WSS and connection-bound exchange handling belong to the caller. Always verify the server final before trusting the login result or a token delivered with it.
  • Registration/storage: generate salts with NewCredential; protect both credential keys and the shared decoy secret. Do not retain the old PHC salted password after migration unless a separate authentication path requires it.
  • Denial of service: enforce request/frame limits before JSON decoding, deadlines, rate limits, and a RAM-appropriate cap on simultaneous KDF calls. Package ceilings are per operation. Credential count and opaque-token count are application-controlled and are not capped by the package.
  • Authorization/revocation: scope JWT issuer/audience to the receiving service. HS256 verifiers can issue tokens; use RS256 when services should only verify. Check session state when logout/rotation needs immediate revocation. Removing a SCRAM credential cannot retract sessions already issued by the app.
  • Privacy: map authentication failures to suitable public responses; avoid logging passwords, proofs, keys, or bearer tokens. The decoy mechanism reduces enumeration signals but does not promise constant-time network behavior.

Performance

NewCredential performs one Argon2 derivation; provisioning through HashPassword followed by MigrateFromPHC performs two. Existing PHC migration continues to reuse its verification KDF for the normal 32-byte digest.

JWT managers now construct their immutable parser once. No password-strength defaults were reduced. RSA key validation/copying occurs at construction. The server's proof path performs bounded hash/HMAC operations, with no Argon2 work.

On Go 1.27.1/linux-amd64, three local benchmark samples measured direct provisioning at 6.61–7.54 ms and ~8.39 MB allocated, versus 13.10–13.72 ms and ~16.79 MB for hash-then-migrate (8 MiB/1-iteration test profile). JWT sign-plus-verify allocations dropped from 88 to 85 per operation (~5133 to 4973 bytes); median time was 14.67 µs before and 13.73 µs after. These short local samples are indicative, not a production capacity claim.

Validation

Local validation on Go 1.27.1/linux-amd64 passed go vet ./... and go test -race -count=1 -coverprofile=... ./..., with 98.1% statement coverage. All seven fuzz targets passed 10-second, two-worker campaigns (over 1.8 million executions combined); the client target was rerun after adding minimum-cost policy. These are smoke campaigns, not exhaustive fuzzing. Chess's service tests and CLI compilation also passed after the dependency update.

govulncheck v1.8.0 reported zero vulnerabilities in reachable calls or imported packages. Its only remaining module-level advisory is GO-2026-5932 for golang.org/x/crypto/openpgp, which this package does not import and which has no fixed release. The scan used Go 1.27.1; consumers must also keep their Go runtime patched.

The test suite includes concurrent replay, registration/removal, credential ownership, protocol sequencing, resource ceilings, canonical encoding, RSA validation, JWT trust scopes, and the compiled end-to-end example. Seven fuzz targets cover PHC parsing/verification, credential import, JWT verification, Bearer parsing, and both SCRAM peer inputs. CI runs vet, race tests, and bounded fuzz smoke tests.

Dependencies were updated to golang.org/x/crypto v0.57.0 and its golang.org/x/sys v0.48.0 requirement without raising Go 1.26. The Argon2 source is unchanged from v0.54.0. The update includes fixes for SSH advisories GO-2026-6303, GO-2026-6354, and GO-2026-6355 in unused sibling packages.

Primary references