Compare commits

...
5 Commits
Author SHA1 Message Date
hanzo-dev c23df0600c iam2: correct the redact comment, derive the scheme constant, close the record
redact()'s comment claimed PasswordHash and AccessSecretHash are json:"-" and
that redact is "defense in depth". Both false and the combination is dangerous:
those fields carry real json tags on purpose (orm serializes to a JSON data
column, so json:"-" means never stored — that silently broke login once), which
makes redact the ONLY thing keeping the digest out of a response. A reader
trusting that comment could delete it and ship hashes to clients. Now says so,
and a test asserts on the raw wire bytes of create/get/list.

parse() matched the literal "argon2id" where SchemeArgon2id already existed.

MIGRATION.md marks the Phase 1 blocker resolved and records the measured
parameter argument, the acceptance-floor-vs-mint-policy distinction, and that
init_data.json seeds zero users — so "bootstrap logs in" means the users API,
not the seed file.

build/vet/gofmt clean; go test -race ./... rc=0.
2026-07-16 14:11:50 -07:00
hanzo-dev d13582f5df iam2: prove login out of the box — fresh bootstrap and a live v1 row
Drives POST /v1/iam/login through the real router rather than the verify seam,
asserting on the envelope the SDK actually branches on ({"status":"ok"} inside a
200) — a status-code assertion would pass while every login failed.

  - fresh bootstrap: seed init_data.json, create the first user over the wire
    (POST /v1/iam/users), sign in by username and by email. No manual step, and
    the stored digest is argon2id.
  - live v1 argon2id row (m=65536,t=1,p=2): signs in. This is the blocker; the
    pre-fix binary returns status=error for the CORRECT password.
  - live v1 bcrypt row: signs in, and the sign-in retires the digest.

Records what init_data.json does NOT do: it declares organizations,
applications, providers and certs and ZERO users — v1's own file declares none,
and internal/seed models none. "Bootstrap" therefore cannot mean "seed a login";
the first credential necessarily comes from the users API, which is the path the
test drives.

Gate tests prove the memory bound by its observable effect (fill the gate; the
operation must not proceed), not by asserting on the channel — which would pass
if nothing consulted it. Hash and argon2id verify are gated, bcrypt verify is
not (~5 KB, no reason to queue), slots are never leaked, and the gate bounds
without deadlocking.

Benchmarks record the parameter argument in executable form. Measured at
GOMAXPROCS=2, the pod's actual CPU budget:

  Hash (m=19456,t=2,p=1)            13.5 ms   19.9 MB/op
  Verify live v1 (m=65536,t=1,p=2)  16.6 ms   67.1 MB/op
  Verify bcrypt (cost 10)           39.3 ms    5.3 KB/op

Two things follow. Argon2id at the OWASP baseline is ~3x FASTER than the bcrypt
it replaces, so retiring bcrypt costs no login latency. And memory is the
exposed axis, worse than estimated: a live v1 row holds 67 MB per in-flight
verify, so ~26 concurrent logins reach this pod's 1750MiB GOMEMLIMIT and OOM it.
That is what the gate bounds, and why it is not decoration.

build/vet/gofmt clean; go test -race ./... rc=0.
2026-07-16 14:09:15 -07:00
hanzo-dev 6fe1cfe9dc iam2: verify against the stored digest — argon2id + bcrypt, upgrade on login
Fixes the blocker: users.VerifyPassword called bcrypt unconditionally, so every
argon2id row failed login. Handed an argon2id PHC string bcrypt does not return
ErrHashTooShort as previously recorded — it parses the 'a' of "$argon2id$" as a
version and returns HashVersionTooNewError. Same outage, and the correct
password is rejected either way.

users now holds no crypto at all. It calls internal/password and keeps no
opinion of its own about the algorithm; the two hardcoded PasswordType="bcrypt"
writes on create/update are gone, replaced by password.Scheme(hash), so the
column is DERIVED from the digest and cannot contradict the bytes it names.

VerifyPassword takes (ctx, db) and re-mints a stale digest in place on a
successful login. That is the only moment the plaintext exists to re-hash from,
so it is the only way the 40 live bcrypt rows ever retire — the alternative is a
forced reset for every account that has not changed its password. Best-effort:
the password is already proven correct, so a storage failure must not fail the
login. It costs one more login to retry.

Proven end-to-end against a real SQLite store, not just at the unit seam:
  - a bcrypt row logs in, is re-minted as argon2id, the salt is cleared, the
    passwordType follows the digest, and the SAME password still works after.
  - a live-shaped argon2id row (m=65536,t=1,p=2) logs in and is left BYTE-FOR-
    BYTE alone, because it is stronger than our mint policy.

Also corrects two comments that described the migration backwards: schema/user.go
called argon2id "the legacy scheme" to be "re-hashed to bcrypt", and login.go
advertised bcrypt verification.

go build ./... rc=0, go vet ./... rc=0, go test ./... rc=0.
2026-07-16 14:01:52 -07:00
hanzo-dev ecaef9f4f0 iam2: add internal/password — one hash, one verify, dispatch on the digest
The digest describes itself, so Verify reads the algorithm and the argon2id
parameters out of the stored bytes rather than from a passwordType column or a
constant. Two facts from the live store force this:

  - Live rows are BOTH argon2id (85) and bcrypt (40). One algorithm on the
    verify path cannot serve them.
  - Live argon2id is m=65536,t=1,p=2 while our mint policy is the OWASP
    baseline m=19456,t=2,p=1. Parameters pinned into Verify would fail all 85.

Hash always mints argon2id at m=19456,t=2,p=1 (OWASP Password Storage Cheat
Sheet baseline), 16-byte crypto/rand salt, 32-byte key. Chosen for this pod:
2 CPUs / 2 GiB (GOMEMLIMIT=1750MiB). Of OWASP's five equivalent sets we take
the cheap end of the memory range — m=47104 is 2.4x the footprint for the same
work, and memory is the attacker-controlled axis on an unauthenticated
endpoint; going the other way (m=7168,t=5) buys 2.5x CPU on a 2-core box.
p=1, not the live p=2, because lanes inside one hash only contend for cores a
pod is already using to serve concurrent logins.

Verify returns (ok, stale). stale drives rehash-on-login and is compared
against a SEPARATE acceptance floor (minCost = the weakest OWASP-equivalent
set, 7168*5 = 35840 KiB-passes) rather than against mint policy. Testing
against mint policy would flag three of OWASP's own equivalent sets as stale,
and would "upgrade" the live m=65536,t=1 rows by halving their memory hardness.
What we mint and what we accept are different questions. Caught by the test,
not by inspection.

argon2id runs under a GOMAXPROCS-wide gate. Each hash holds 19 MiB live, so
unbounded concurrency makes login a memory-exhaustion lever: ~90 concurrent
requests reach this pod's GOMEMLIMIT and the process dies. With p=1 only
GOMAXPROCS hashes progress anyway, so the bound costs no throughput.

Tests carry golden digests minted by v1's exact pinned library
(alexedwards/argon2id v0.0.0-20211130144151-3585854a6387, DefaultParams
m=65536,t=1,p=2) — byte-shaped like the real rows. Verifying a digest minted by
something else is the whole point: a round-trip of our own hasher passes while
every live login is broken. Malformed digests fail closed (argon2.IDKey panics
on a zero parameter; a corrupt digest must not reach it). No dependency added:
x/crypto was already required.
2026-07-16 13:57:04 -07:00
hanzo-dev 24927d18c5 iam2: correct the argon2id blocker with live data — bcrypt rows still exist
Measured the live prod store instead of inferring from v1's code paths. The
previous note claimed every live row is argon2id and that bcrypt could be
deleted. Both are false, and acting on them would have broken more logins
than it fixed.

Read through the product's own read-only codec (iam orgdb query: HKDF KEK ->
AES-GCM DEK unwrap -> SQLCipher) across all 114 per-org DBs in hanzo/iam
v1.31.27, classified by the hash bytes rather than the type column:

  argon2id (m=65536,t=1,p=2)  85
  (empty, federated)          63
  bcrypt ($2a$10,$2b$10,$2b$12) 40

bcrypt survives because sanitizeOrgPasswordType rewrites the ORGANIZATION's
type while UpdateUserPassword only re-stamps a USER's row when that user's
password is next written. Untouched passwords keep their bcrypt digest, and
3 orgs remain bcrypt outright.

So: verify dispatches on the self-describing hash bytes (not PasswordType,
which can disagree with the bytes it describes); argon2id params are read
from the stored hash (live rows are m=65536,t=1,p=2, which differs from the
current OWASP recommendation, so a hardcoded verify param would fail all 85);
and rehash-on-login is required rather than optional, because it is the only
thing that retires the 40 bcrypt rows.

Recording the finding before the fix so the evidence survives the session.
2026-07-16 13:48:21 -07:00
11 changed files with 1242 additions and 60 deletions
+91 -15
View File
@@ -40,21 +40,97 @@ Phases 04 are additive and non-destructive — v1 stays live and authoritativ
until Phase 5. Routes carry a `/v1/iam/*` prefix through the transition so
they are orthogonal to the live `/v1/iam/*` mount; the prefix collapses at §6.
**Phase 1 blocker — password hashes are `argon2id`, not bcrypt (breaks EVERY login).**
`users.VerifyPassword` (`internal/users/users.go`) calls `bcrypt.CompareHashAndPassword`
unconditionally, and it is the only path credential login takes (`internal/oidc/login.go`).
Every live v1 row is `argon2id`: `object/organization.go sanitizeOrgPasswordType`
rewrites `""`/`bcrypt`/`plain``argon2id` on both AddOrganization and
UpdateOrganization, `CreatePersonalOrganization` inserts it, `init_data.json`
declares it, and `object/user_cred.go UpdateUserPassword` stamps it. Handed an
argon2id PHC string, bcrypt returns `ErrHashTooShort` — so on cutover **100% of
credential logins fail**, for every existing user, immediately. v1 resolves the
algorithm per row: `object/check.go` reads `user.PasswordType`, falling back to
`organization.PasswordType`, then dispatches through `cred.GetCredManager`.
iam2 must do the same — the hash algorithm is a property of the stored row, never
a constant. Verify-only (never re-hash on read); a rehash-on-login upgrade is a
separate, deliberate decision. This is the sharpest reason parity is proven by
`compare` against real rows, not by tests over rows iam2 wrote itself.
**Phase 1 blocker — RESOLVED (`internal/password`).** Login verified with bcrypt
only; live rows are BOTH argon2id and bcrypt.
`users.VerifyPassword` called `bcrypt.CompareHashAndPassword` unconditionally, and
it is the only path credential login takes (`internal/oidc/login.go`), so every
argon2id user failed login. (Handed an argon2id PHC string bcrypt does *not*
return `ErrHashTooShort` as first recorded — it reads the `a` of `$argon2id$` as
a version and returns `HashVersionTooNewError`. Same outage; the correct password
is rejected either way.) v1 resolves the algorithm per row: `object/check.go`
reads `user.PasswordType`, falling back to `organization.PasswordType`, then
dispatches through `cred.GetCredManager`. The hash algorithm is a property of the
stored row, never a constant.
*Measured against the live prod store (2026-07-16), not inferred.* The earlier
claim here — "every live v1 row is argon2id, so bcrypt can be deleted" — is
**false**, and deleting the bcrypt path would have broken more logins than it
fixed. Read via the product's own read-only codec (`iam orgdb query`, HKDF KEK →
AES-GCM DEK unwrap → SQLCipher) across all 114 per-org DBs in `hanzo/iam`
(`v1.31.27`), classifying by the hash bytes themselves:
| stored hash | users | note |
|---|---:|---|
| `argon2id` (`m=65536,t=1,p=2`) | 85 | `alexedwards/argon2id` `DefaultParams` |
| *(empty)* | 63 | federated/OAuth — no credential login |
| `bcrypt` (`$2a$10$`×24, `$2b$10$`×15, `$2b$12$`×1) | 40 | **still live** |
Why bcrypt survives: `sanitizeOrgPasswordType` rewrites the **organization**'s
type, and `UpdateUserPassword` only re-stamps a **user**'s row when that user's
password is next written. A user whose password has not changed keeps its bcrypt
digest indefinitely — 3 orgs are still `bcrypt` outright. So both algorithms must
verify, and bcrypt rows only ever migrate if login rehashes them.
The fix, in `internal/password` — the one place that mints a digest and the one
place that checks one. Nothing else in the tree imports a hash function:
1. **Verify dispatches on the hash bytes, not a type column.** The digest is
self-describing (`$argon2id$…` / `$2a$|$2b$|$2y$…`). `PasswordType` is a second
source of truth that can disagree with the bytes it describes, so it is not read
on the verify path — and it is now *derived* from the digest (`password.Scheme`)
wherever it is written, so it cannot contradict it.
2. **Params come from the stored hash, never from a constant.** Live rows are
`m=65536,t=1,p=2`; our mint policy is the OWASP baseline `m=19456,t=2,p=1`.
Parameters pinned into verify would fail all 85 argon2id rows.
3. **Rehash-on-login**, in `users.VerifyPassword` — the only thing that retires
the 40 bcrypt rows, since a successful login is the only moment the plaintext
exists to re-hash from. Best-effort: the password is already proven correct, so
a storage failure must not fail the login. (This supersedes the earlier
"verify-only" note, written believing no bcrypt rows existed.)
4. **Staleness is judged against an acceptance floor, not against mint policy.**
The floor is the weakest of OWASP's five equivalent sets (`m=7168,t=5` → 35840
KiB-passes, on the memory-time product). Comparing against mint policy instead
would flag three of OWASP's own equivalent sets as stale *and* "upgrade" the
live `m=65536,t=1` rows by halving their memory hardness — a downgrade wearing
the word upgrade.
**Parameters, and why.** OWASP's baseline `m=19456 (19 MiB), t=2, p=1`, 16-byte
`crypto/rand` salt, 32-byte key. Measured at `GOMAXPROCS=2` — the iam pod's real
budget (2 CPU, 2 GiB, `GOMEMLIMIT=1750MiB`):
| operation | latency | memory/op |
|---|---:|---:|
| Hash (`m=19456,t=2,p=1`) | 13.5 ms | 19.9 MB |
| Verify live v1 (`m=65536,t=1,p=2`) | 16.6 ms | 67.1 MB |
| Verify bcrypt (cost 10) | 39.3 ms | 5.3 KB |
Argon2id at the baseline is ~3x **faster** than the bcrypt it replaces, so
retiring bcrypt costs no login latency. Memory is the exposed axis: of OWASP's
equivalent sets we take the cheap end of the memory range (`m=47104` is 2.4x the
footprint for the same work; `m=7168,t=5` is 2.5x the CPU on a 2-core box).
Because every in-flight argon2id hash holds its full `m`, and login is
unauthenticated, argon2id runs under a `GOMAXPROCS`-wide gate — without it ~26
concurrent logins against live-parameter rows reach `GOMEMLIMIT` and OOM the pod,
which is the one failure that logs everybody out at once. With `p=1` only
`GOMAXPROCS` hashes progress anyway, so the bound costs no throughput.
**Proven** (`go test -race ./...`): a digest minted by v1's exact pinned library
(`alexedwards/argon2id v0.0.0-20211130144151`, `DefaultParams` = the live
`m=65536,t=1,p=2`) verifies; both live shapes sign in through the real
`POST /v1/iam/login`; a fresh bootstrap signs in with no manual step; a bcrypt row
is re-minted in place and the same password still works. Testing only our own
hasher's round-trip would have passed while every live login was broken.
> **`init_data.json` seeds no users.** It declares organizations, applications,
> providers and certs — v1's own file declares **zero** users, and `internal/seed`
> models none. "Bootstrap" cannot mean "seed a login": the first credential comes
> from the users API. The `passwordType: argon2id` in that file is the
> *organization*'s, which iam2 does not read (the digest decides).
This is the sharpest reason parity is proven by `compare` against real rows, not
by tests over rows iam2 wrote itself — a round-trip test of our own hasher passes
happily while every live login is broken.
**Phase 2 residual — the front door (blocks Phase 5).** The OIDC/OAuth2 protocol
surface is complete, but HIP-0111 §6's *native front-door* surface — what the
+5 -3
View File
@@ -22,8 +22,10 @@ import (
// then exchanges it at /v1/iam/oauth/token. Login by EMAIL or USERNAME.
//
// This is the interactive-flow counterpart to the token endpoint: login mints
// the code, /token redeems it. Password verification is bcrypt (constant-time),
// never plaintext, and the hash never crosses a response.
// the code, /token redeems it. Password verification is constant-time, never
// plaintext, and the hash never crosses a response. The algorithm is whatever
// the stored digest says it is (internal/password) — this path holds no opinion
// about it, and a successful login re-mints a legacy digest in place.
// PathLogin is the canonical credential-login endpoint.
const PathLogin = "/v1/iam/login"
@@ -69,7 +71,7 @@ func loginHandler(db orm.DB) zip.Handler {
}
// One opaque failure for "no such user" and "wrong password" — no oracle
// that reveals whether the account exists.
if user == nil || !users.VerifyPassword(user, f.Password) {
if user == nil || !users.VerifyPassword(ctx, db, user, f.Password) {
return httpx.Err(c, "the username or password is incorrect")
}
+192
View File
@@ -0,0 +1,192 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package oidc
import (
"os"
"path/filepath"
"testing"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/password"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/seed"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/internal/users"
)
// The two logins that must work with no manual step, driven through the REAL
// router (POST /v1/iam/login), not through the verify seam:
//
// 1. a fresh bootstrap — seed init_data.json, create the first user, sign in;
// 2. an existing live v1 row — an argon2id digest written by v1, signed in
// against by iam2 unchanged. This is the blocker itself.
//
// Both assert on the wire contract the @hanzo/iam SDK actually branches on:
// {"status":"ok"} on a 200. A handler that returned "error" inside a 200 would
// pass a status-code assertion while every real login failed.
// postLogin drives a bare (type=login) credential sign-in and returns the
// envelope. type=login is the pure credential path — no app/PKCE required —
// so a failure here is a failure of password verification and nothing else.
func postLogin(t *testing.T, app *zip.App, org, username, pw string) map[string]any {
t.Helper()
_, body := do(t, app, jsonReq("POST", PathLogin, map[string]string{
"organization": org,
"username": username,
"password": pw,
"type": "login",
}))
return decode(t, body)
}
// newFullServer mounts the user CRUD surface alongside the OIDC surface on one
// store, so a test can walk the whole out-of-the-box path over HTTP: create a
// user, then sign in as them.
func newFullServer(t *testing.T) (*zip.App, orm.DB) {
t.Helper()
db := openTestDB(t)
app := zip.New(zip.Config{AppName: "iam2-outofthebox-test", DisableStartupMessage: true})
Mount(app, db)
users.Mount(app, db)
return app, db
}
// storedScheme reports the algorithm a user's digest is actually stored under,
// read from the bytes.
func storedScheme(t *testing.T, db orm.DB, owner, name string) string {
t.Helper()
u, err := store.GetUserByName(tctx(), db, owner, name)
if err != nil || u == nil {
t.Fatalf("load user %s/%s: %v", owner, name, err)
}
return password.Scheme(u.PasswordHash)
}
// TestFreshBootstrapCanLogIn: a brand-new store seeded from init_data.json, a
// first user created through the users API, and a successful sign-in — with no
// manual hash surgery in between.
//
// Note what init_data.json does NOT do: it declares organizations, applications,
// providers and certs, but ZERO users (v1's own file declares none either, and
// internal/seed deliberately models no user). So "bootstrap" cannot mean "seed a
// login" — the first credential necessarily comes from the users API, which is
// the path this test drives.
func TestFreshBootstrapCanLogIn(t *testing.T) {
app, db := newFullServer(t)
ctx := tctx()
// A fresh store, seeded exactly the way the binary seeds itself at boot.
// passwordType on the organization is argon2id — the same declaration the
// live v1 file carries.
dir := t.TempDir()
path := filepath.Join(dir, "init_data.json")
if err := os.WriteFile(path, []byte(`{
"organizations": [{"owner":"admin","name":"hanzo","displayName":"Hanzo","passwordType":"argon2id"}],
"applications": [{"owner":"admin","name":"hanzo-console","clientId":"hanzo-console","organization":"hanzo","enablePassword":true}]
}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := seed.FromInitData(ctx, db, path); err != nil {
t.Fatalf("seed from init_data.json: %v", err)
}
// The first user, created over the wire through the ordinary users API — no
// special bootstrap path, and no hash chosen by the caller.
const founderPw = "a fresh out-of-the-box password"
resp, body := do(t, app, jsonReq("POST", "/v1/iam/users", users.CreateInput{
User: schema.User{Owner: "hanzo", Name: "founder", Email: "founder@hanzo.ai"},
Password: founderPw,
}))
if resp.StatusCode != 200 {
t.Fatalf("create first user: HTTP %d: %s", resp.StatusCode, body)
}
// It must be stored under the algorithm we mint, with no manual step.
if got := storedScheme(t, db, "hanzo", "founder"); got != password.SchemeArgon2id {
t.Fatalf("a freshly created user was stored under %q, want argon2id", got)
}
if m := postLogin(t, app, "hanzo", "founder", founderPw); m["status"] != "ok" {
t.Fatalf("fresh bootstrap could not log in: status=%v msg=%v", m["status"], m["msg"])
}
// Login by email, too — the portal posts either.
if m := postLogin(t, app, "hanzo", "founder@hanzo.ai", founderPw); m["status"] != "ok" {
t.Fatalf("fresh bootstrap could not log in by email: status=%v msg=%v", m["status"], m["msg"])
}
if m := postLogin(t, app, "hanzo", "founder", "wrong"); m["status"] != "error" {
t.Fatal("a wrong password logged in")
}
}
// TestLiveV1RowCanLogIn is the blocker, end to end: a row shaped exactly like
// the 85 argon2id rows in the live store signs in through the real endpoint.
// Against the pre-fix binary this returns status=error for the CORRECT password.
func TestLiveV1RowCanLogIn(t *testing.T) {
app, db := newFullServer(t)
// Minted by v1's exact pinned library (alexedwards/argon2id
// v0.0.0-20211130144151, DefaultParams m=65536,t=1,p=2) — see
// internal/password/password_test.go.
const (
v1Digest = "$argon2id$v=19$m=65536,t=1,p=2$pp/ox8H4VMz2MEVeKoOuxg$Vqb3kOJtdw9vdDMTvJG/yn8U81IwcuidSJFXMUaI+u0"
v1Password = "correct horse battery staple"
)
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = "v1user"
u.Email = "v1user@hanzo.ai"
u.PasswordHash = v1Digest
u.PasswordType = "argon2id"
u.SetId("hanzo/v1user")
if err := u.CreateCtx(tctx()); err != nil {
t.Fatal(err)
}
if m := postLogin(t, app, "hanzo", "v1user", v1Password); m["status"] != "ok" {
t.Fatalf("a live v1 argon2id row could not log in — the cutover blocker is not fixed: status=%v msg=%v", m["status"], m["msg"])
}
if m := postLogin(t, app, "hanzo", "v1user", "wrong"); m["status"] != "error" {
t.Fatal("a wrong password logged in against a v1 row")
}
}
// TestLiveV1BcryptRowCanLogInAndUpgrades: the other 40 live rows. They sign in,
// and the sign-in retires the legacy digest — the whole reason the bcrypt path
// is kept rather than deleted.
func TestLiveV1BcryptRowCanLogInAndUpgrades(t *testing.T) {
app, db := newFullServer(t)
// $2a$10$ — the shape of 24 of the live bcrypt rows.
const (
v1Digest = "$2a$10$TD8./C3ff5vaeBHgUEfAW.55wo2O7e0RGGoXBOP1fv6mkQtUpVrv6"
v1Password = "hunter2"
)
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = "bcryptuser"
u.Email = "bcryptuser@hanzo.ai"
u.PasswordHash = v1Digest
u.PasswordType = "bcrypt"
u.SetId("hanzo/bcryptuser")
if err := u.CreateCtx(tctx()); err != nil {
t.Fatal(err)
}
if m := postLogin(t, app, "hanzo", "bcryptuser", v1Password); m["status"] != "ok" {
t.Fatalf("a live v1 bcrypt row could not log in: status=%v msg=%v", m["status"], m["msg"])
}
// The login retired the legacy digest, in the store.
if got := storedScheme(t, db, "hanzo", "bcryptuser"); got != password.SchemeArgon2id {
t.Fatalf("bcrypt row still %q after a successful login — it would never migrate", got)
}
// And the user notices nothing: the same password still signs in, now
// against the re-minted digest.
if m := postLogin(t, app, "hanzo", "bcryptuser", v1Password); m["status"] != "ok" {
t.Fatalf("the same password stopped working after the upgrade: status=%v msg=%v", m["status"], m["msg"])
}
}
+100 -3
View File
@@ -10,6 +10,7 @@ import (
"github.com/hanzoai/orm"
"github.com/hanzoai/iam2/internal/password"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/store"
"github.com/hanzoai/iam2/internal/users"
@@ -47,10 +48,106 @@ func TestPasswordHashPersists(t *testing.T) {
t.Fatal("AccessSecretHash did not persist")
}
// The retrieved hash actually verifies the password.
if !users.VerifyPassword(got, "s3cret-pw") {
if !users.VerifyPassword(ctx, db, got, "s3cret-pw") {
t.Fatal("persisted hash does not verify the password")
}
if users.VerifyPassword(got, "wrong-pw") {
t.Fatal("wrong password verified — bcrypt broken")
if users.VerifyPassword(ctx, db, got, "wrong-pw") {
t.Fatal("wrong password verified — verification broken")
}
}
// TestLegacyBcryptRowUpgradesOnLogin proves the transparent migration against a
// REAL store: a bcrypt row (40 of these are live today) verifies, is re-minted
// as argon2id in place, and the NEXT login verifies against the new digest.
// Without this, a bcrypt row stays bcrypt forever — nothing else in the system
// ever holds the plaintext to re-hash from.
func TestLegacyBcryptRowUpgradesOnLogin(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
const pw = "legacy-password"
hash, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = "legacyuser"
u.Email = "legacy@hanzo.ai"
u.PasswordHash = string(hash)
u.PasswordType = "bcrypt"
u.PasswordSalt = "a-legacy-v1-salt"
if err := u.Create(); err != nil {
t.Fatal(err)
}
// First login: verifies under bcrypt, and re-mints.
got, err := store.GetUserByEmail(ctx, db, "hanzo", "legacy@hanzo.ai")
if err != nil || got == nil {
t.Fatalf("lookup: %v", err)
}
if !users.VerifyPassword(ctx, db, got, pw) {
t.Fatal("a live-shaped bcrypt row failed to log in")
}
// Re-read from the store: the upgrade must have been PERSISTED, not just
// applied to the in-memory copy.
after, err := store.GetUserByEmail(ctx, db, "hanzo", "legacy@hanzo.ai")
if err != nil || after == nil {
t.Fatalf("re-read: %v", err)
}
if password.Scheme(after.PasswordHash) != password.SchemeArgon2id {
t.Fatalf("digest not re-minted as argon2id; scheme is %q", password.Scheme(after.PasswordHash))
}
if after.PasswordType != password.SchemeArgon2id {
t.Fatalf("passwordType = %q, want argon2id — the column now contradicts the digest", after.PasswordType)
}
if after.PasswordSalt != "" {
t.Fatal("legacy passwordSalt survived the upgrade — it describes nothing under argon2id")
}
// The re-minted digest still authenticates the SAME password — the upgrade
// must be invisible to the user.
if !users.VerifyPassword(ctx, db, after, pw) {
t.Fatal("re-minted digest does not verify the original password — the upgrade locked the user out")
}
if users.VerifyPassword(ctx, db, after, "wrong-pw") {
t.Fatal("wrong password verified after upgrade")
}
}
// TestArgon2idRowIsNotRewrittenOnLogin: the 85 live argon2id rows are stronger
// than our mint policy, so a login must leave them exactly as they are.
func TestArgon2idRowIsNotRewrittenOnLogin(t *testing.T) {
db := openTestDB(t)
ctx := context.Background()
// A live-shaped v1 digest (m=65536,t=1,p=2), minted by alexedwards/argon2id
// v0.0.0-20211130144151 — see internal/password/password_test.go.
const (
liveDigest = "$argon2id$v=19$m=65536,t=1,p=2$pp/ox8H4VMz2MEVeKoOuxg$Vqb3kOJtdw9vdDMTvJG/yn8U81IwcuidSJFXMUaI+u0"
livePw = "correct horse battery staple"
)
u := orm.New[schema.User](db)
u.Owner = "hanzo"
u.Name = "argonuser"
u.Email = "argon@hanzo.ai"
u.PasswordHash = liveDigest
u.PasswordType = "argon2id"
if err := u.Create(); err != nil {
t.Fatal(err)
}
got, err := store.GetUserByEmail(ctx, db, "hanzo", "argon@hanzo.ai")
if err != nil || got == nil {
t.Fatalf("lookup: %v", err)
}
if !users.VerifyPassword(ctx, db, got, livePw) {
t.Fatal("a live-shaped argon2id row failed to log in — this is the blocker")
}
after, err := store.GetUserByEmail(ctx, db, "hanzo", "argon@hanzo.ai")
if err != nil || after == nil {
t.Fatalf("re-read: %v", err)
}
if after.PasswordHash != liveDigest {
t.Fatal("a stronger-than-policy argon2id digest was rewritten on login — that weakens the account")
}
}
+42
View File
@@ -0,0 +1,42 @@
package oidc
import (
"strings"
"testing"
"github.com/hanzoai/iam2/internal/schema"
"github.com/hanzoai/iam2/internal/users"
)
// TestUserReadNeverLeaksDigest: PasswordHash carries a real json tag (it must
// persist), so only redact() keeps it out of a response. If a read path ever
// skips redact, the digest ships to the client. Assert on the raw wire bytes.
func TestUserReadNeverLeaksDigest(t *testing.T) {
app, _ := newFullServer(t)
const pw = "leak-check-password"
resp, body := do(t, app, jsonReq("POST", "/v1/iam/users", users.CreateInput{
User: schema.User{Owner: "hanzo", Name: "leaky", Email: "leaky@hanzo.ai"},
Password: pw,
}))
if resp.StatusCode != 200 {
t.Fatalf("create: HTTP %d: %s", resp.StatusCode, body)
}
assertNoDigest(t, "create response", body)
_, body = do(t, app, jsonReq("GET", "/v1/iam/users/get", map[string]string{"owner": "hanzo", "name": "leaky"}))
assertNoDigest(t, "get response", body)
_, body = do(t, app, jsonReq("GET", "/v1/iam/users", map[string]string{"owner": "hanzo"}))
assertNoDigest(t, "list response", body)
}
func assertNoDigest(t *testing.T, where string, body []byte) {
t.Helper()
s := string(body)
for _, marker := range []string{"$argon2id$", "$2a$", "$2b$", "passwordHash", "leak-check-password"} {
if strings.Contains(s, marker) {
t.Fatalf("%s leaked %q", where, marker)
}
}
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package password
import "testing"
// The parameter choice in this package is a claim about latency and memory on a
// 2-CPU / 2 GiB pod. These benchmarks are that claim in executable form — run
// them with GOMAXPROCS=2 to mirror the iam pod:
//
// GOMAXPROCS=2 go test ./internal/password/ -run XXX -bench . -benchmem
//
// Measured (GOMAXPROCS=2, 2026-07-16):
//
// Hash (m=19456,t=2,p=1) 13.5 ms 19.9 MB/op
// Verify live v1 (m=65536,t=1,p=2) 16.6 ms 67.1 MB/op
// Verify bcrypt (cost 10) 39.3 ms 5.3 KB/op
//
// Two things follow. Argon2id at the OWASP baseline is ~3x FASTER than the
// bcrypt it replaces, so retiring bcrypt costs no login latency. And memory,
// not time, is the exposed axis: a live v1 row holds 67 MB per in-flight
// verify, so ~26 concurrent logins would reach this pod's 1750MiB GOMEMLIMIT.
// That is what `gate` bounds.
func BenchmarkHashPolicy(b *testing.B) {
for i := 0; i < b.N; i++ {
if _, err := Hash("a representative password"); err != nil {
b.Fatal(err)
}
}
}
// BenchmarkVerifyLiveV1Params measures what the 85 live argon2id rows actually
// cost us on the login path — the parameters are theirs, not ours.
func BenchmarkVerifyLiveV1Params(b *testing.B) {
for i := 0; i < b.N; i++ {
if ok, _ := Verify(liveArgon2idDigest, liveArgon2idPassword); !ok {
b.Fatal("did not verify")
}
}
}
// BenchmarkVerifyBcrypt10 measures the 40 live bcrypt rows, for comparison.
func BenchmarkVerifyBcrypt10(b *testing.B) {
for i := 0; i < b.N; i++ {
if ok, _ := Verify(liveBcrypt10Digest, liveBcryptPassword); !ok {
b.Fatal("did not verify")
}
}
}
+124
View File
@@ -0,0 +1,124 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package password
import (
"testing"
"time"
"golang.org/x/crypto/bcrypt"
)
// The memory bound is only real if the argon2id paths actually consult the
// gate. These tests prove that by its observable effect — fill the gate, and
// the operation must not be able to proceed — rather than by asserting on the
// channel itself, which would pass even if nothing used it.
// fillGate takes every slot and returns a release func.
func fillGate(t *testing.T) func() {
t.Helper()
for i := 0; i < cap(gate); i++ {
gate <- struct{}{}
}
released := false
return func() {
if released {
return
}
released = true
for i := 0; i < cap(gate); i++ {
<-gate
}
}
}
// blocks reports whether fn is still running after a grace period.
func blocks(fn func()) bool {
done := make(chan struct{})
go func() { fn(); close(done) }()
select {
case <-done:
return false
case <-time.After(150 * time.Millisecond):
return true
}
}
// TestHashIsGated: minting holds 19 MiB, so it must wait for a slot.
func TestHashIsGated(t *testing.T) {
release := fillGate(t)
defer release()
if !blocks(func() { _, _ = Hash("x") }) {
t.Fatal("Hash ran with the gate full — it does not take a slot, so the memory bound is fiction")
}
}
// TestVerifyArgon2idIsGated is the one that matters: verify is the
// UNAUTHENTICATED path, and a live v1 row holds 67 MB per in-flight call.
func TestVerifyArgon2idIsGated(t *testing.T) {
release := fillGate(t)
defer release()
if !blocks(func() { _, _ = Verify(liveArgon2idDigest, liveArgon2idPassword) }) {
t.Fatal("argon2id verify ran with the gate full — an unauthenticated caller sets the memory peak")
}
}
// TestGatedWorkCompletesOnceSlotsFree: the gate must bound concurrency, not
// deadlock it. A blocked hash proceeds the moment a slot is returned, and
// returns its own slot afterwards.
func TestGatedWorkCompletesOnceSlotsFree(t *testing.T) {
release := fillGate(t)
done := make(chan string, 1)
go func() {
d, err := Hash("eventually")
if err != nil {
done <- ""
return
}
done <- d
}()
release() // hand the slots back
select {
case digest := <-done:
if digest == "" {
t.Fatal("Hash errored once unblocked")
}
if ok, _ := Verify(digest, "eventually"); !ok {
t.Fatal("digest minted through the gate does not verify")
}
case <-time.After(30 * time.Second):
t.Fatal("Hash never completed after slots were freed — the gate deadlocks")
}
// Every slot must have been returned: the gate is reusable, not leaked.
if len(gate) != 0 {
t.Fatalf("%d gate slots leaked — a leak would eventually starve every login", len(gate))
}
}
// TestBcryptVerifyIsNotGated: bcrypt holds ~5 KB, not 19-67 MB, so it has no
// business queueing behind memory-hungry argon2id work. Gating it would make
// the 40 legacy rows contend for a bound that exists for a cost they do not
// have.
//
// Deliberately uses a MinCost digest rather than the live cost-10 vector: at
// cost 10 bcrypt takes ~39ms, which inflates past any grace period under -race
// and would make this assert on the race detector's overhead instead of on the
// gate. The claim under test is "the bcrypt branch takes no slot", and cost is
// irrelevant to it.
func TestBcryptVerifyIsNotGated(t *testing.T) {
cheap, err := bcrypt.GenerateFromPassword([]byte(liveBcryptPassword), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
release := fillGate(t)
defer release()
if blocks(func() { _, _ = Verify(string(cheap), liveBcryptPassword) }) {
t.Fatal("bcrypt verify queued behind the argon2id gate — it holds ~5 KB and need not")
}
}
+255
View File
@@ -0,0 +1,255 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
// Package password is the one place Hanzo IAM turns a plaintext password into a
// stored digest, and the one place it checks a plaintext against one. Nothing
// else in the tree imports a hash function; a second opinion about how a
// password is stored is the bug this package exists to make impossible.
//
// # The digest describes itself
//
// Verify dispatches on the stored bytes, never on a type column. Every digest
// we can encounter names its own algorithm and carries its own parameters:
//
// $argon2id$v=19$m=65536,t=1,p=2$<salt>$<hash> argon2id (PHC string)
// $2a$10$<salt+hash> bcrypt (also $2b$, $2y$)
//
// A `passwordType` column alongside the digest is a second source of truth that
// can disagree with the bytes it describes — and the two are written by
// different code at different times, so eventually they do. The bytes win,
// because the bytes are what we must actually verify against.
//
// # Parameters are read, never assumed
//
// Argon2id parameters come out of the stored hash. They are policy only when
// MINTING a digest (Hash); on the verify path they are data. Pinning today's
// parameters into Verify would make every hash minted under yesterday's
// parameters unreadable the day we tune them — which is a self-inflicted
// outage that lands on the login path, where it is most expensive.
package password
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"runtime"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
// Argon2id parameters for every NEW digest, per the OWASP Password Storage
// Cheat Sheet ("Password Storage Cheat Sheet", Argon2id section): "Use Argon2id
// with a minimum configuration of 19 MiB of memory, an iteration count of 2,
// and 1 degree of parallelism."
//
// OWASP offers five sets it calls equivalent — m=47104,t=1 / m=19456,t=2 /
// m=12288,t=3 / m=9216,t=4 / m=7168,t=5 (all p=1). We take m=19456,t=2,p=1, the
// stated baseline, because of what this binary actually runs on: the iam pod is
// limited to 2 CPUs and 2 GiB (GOMEMLIMIT=1750MiB). Memory is the scarce,
// attacker-controlled axis there — every in-flight hash holds `memoryKiB` live
// and login is unauthenticated — so of the equivalent sets we prefer the cheap
// end of the memory range over m=47104 (46 MiB), which is 2.4x the footprint
// for the same work. Going further down the ladder (m=7168,t=5) would trade
// that memory for 2.5x the CPU time on a 2-core box, where CPU is what bounds
// login throughput. m=19456,t=2,p=1 is the balance point for this pod.
//
// p=1 (not the p=2 that the live v1 rows carry) because parallelism inside one
// hash cannot help a 2-CPU pod that is already serving concurrent logins: the
// lanes just contend for the same cores. One lane per hash keeps per-login cost
// predictable and lets `gate` do the scheduling.
const (
memoryKiB = 19456 // 19 MiB
timeCost = 2
lanes = 1
saltLen = 16 // 128-bit random salt, per PHC/RFC 9106
keyLen = 32 // 256-bit derived key
)
// minCost is the ACCEPTANCE floor: an argon2id digest at or above it is
// current enough to keep, and only one below it is re-minted on login. It is a
// separate question from what we mint, and conflating the two is a bug in each
// direction — mint policy that doubles as an acceptance test re-hashes accounts
// that are already fine (and, when a live row is stronger than policy, actively
// weakens them).
//
// The unit is the memory-time product (KiB-passes) — Argon2's area-time cost,
// the axis its parameters trade along. The floor is the weakest of the five
// configurations OWASP itself calls equivalent (m=7168, t=5 -> 35840), so every
// OWASP-current digest is accepted, including the three that sit just below our
// own mint cost of 19456*2=38912.
const minCost = 7168 * 5 // 35840 KiB-passes
// maxPasswordLen bounds what we will MINT a digest for. OWASP: "you should
// enforce a maximum password length". It is deliberately not enforced on the
// verify path — a bound introduced today must never lock out an account whose
// password was accepted yesterday.
const maxPasswordLen = 4096
// gate bounds how many argon2id computations run at once. Each one holds
// memoryKiB (19 MiB) of live memory for its duration, so without a bound an
// unauthenticated login endpoint is a memory-exhaustion lever: ~90 concurrent
// requests reach this pod's 1750MiB GOMEMLIMIT and the process is killed —
// which is the one failure that logs everybody out at once.
//
// GOMAXPROCS is the right bound and costs nothing: with p=1 only GOMAXPROCS
// hashes can make real progress anyway, so admitting more buys queueing and a
// larger peak, not throughput. Excess logins wait here (cheap — a parked
// goroutine) instead of allocating (expensive). On the iam pod this caps
// argon2id's footprint at 2 x 19 MiB.
var gate = make(chan struct{}, max(1, runtime.GOMAXPROCS(0)))
// ErrPasswordTooLong is returned by Hash for an input over maxPasswordLen.
var ErrPasswordTooLong = errors.New("password: too long")
// Hash derives a new digest from plaintext. It is always argon2id at the
// current parameters — the ONE way a password becomes storable. The returned
// PHC string carries its own salt and parameters, so it stays verifiable after
// those parameters change.
func Hash(plaintext string) (string, error) {
if len(plaintext) > maxPasswordLen {
return "", ErrPasswordTooLong
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("password: read salt: %w", err)
}
key := idKey([]byte(plaintext), salt, timeCost, memoryKiB, lanes, keyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, memoryKiB, timeCost, lanes,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
), nil
}
// Verify reports whether plaintext matches digest, and whether digest should be
// replaced by a freshly minted one (stale).
//
// stale is only meaningful when ok is true — never re-hash on a failed guess.
// It is true when the digest is not argon2id (bcrypt, which we are retiring),
// or when it is argon2id weaker than current policy. It is deliberately NOT
// "the parameters differ from policy": the live v1 rows are m=65536,t=1,p=2
// (64 MiB), which is *stronger* than our m=19456,t=2,p=1 baseline, and
// re-hashing those to policy would weaken 85 accounts in the name of an
// upgrade. See stale() for the comparison.
func Verify(digest, plaintext string) (ok, stale bool) {
switch Scheme(digest) {
case SchemeArgon2id:
return verifyArgon2id(digest, plaintext)
case SchemeBcrypt:
// bcrypt: correct today, but not the algorithm we mint. A successful
// verify is the only moment we hold the plaintext, so it is the only
// moment we can replace the digest.
err := bcrypt.CompareHashAndPassword([]byte(digest), []byte(plaintext))
return err == nil, err == nil
default:
// Unknown, empty or corrupt digest: fail closed. Never treat an
// unparseable digest as a match.
return false, false
}
}
// The schemes a stored digest can be under. SchemeArgon2id is the only one we
// mint; SchemeBcrypt is verify-and-retire.
const (
SchemeArgon2id = "argon2id"
SchemeBcrypt = "bcrypt"
)
// Scheme names the algorithm a digest is stored under, read from the digest
// itself, or "" if it is under none we know.
//
// It exists so that the stored `passwordType` is DERIVED from the bytes it
// describes instead of being chosen alongside them. Those are the two writes
// that drift apart: v1 sets the organization's type in one place and the user's
// digest in another, so a row can claim argon2id while holding bcrypt bytes.
// Nothing here reads the column to decide how to verify — Verify asks the
// digest — but as long as the column exists it must not be able to lie.
func Scheme(digest string) string {
switch {
case strings.HasPrefix(digest, "$argon2id$"):
return SchemeArgon2id
case strings.HasPrefix(digest, "$2a$"),
strings.HasPrefix(digest, "$2b$"),
strings.HasPrefix(digest, "$2y$"):
return SchemeBcrypt
default:
return ""
}
}
// verifyArgon2id checks plaintext against a PHC argon2id digest, using the
// parameters the digest itself carries.
func verifyArgon2id(digest, plaintext string) (ok, stale bool) {
memory, time, threads, salt, want, err := parse(digest)
if err != nil {
return false, false
}
got := idKey([]byte(plaintext), salt, time, memory, threads, uint32(len(want)))
if subtle.ConstantTimeCompare(got, want) != 1 {
return false, false
}
return true, weaker(memory, time)
}
// weaker reports whether an argon2id digest at these parameters is below the
// acceptance floor, and so should be re-minted on the next successful login.
//
// It compares against minCost, NOT against our mint parameters. Testing against
// what we mint would flag three of OWASP's five equivalent sets (12288x3 and
// 9216x4 = 36864, 7168x5 = 35840 all sit under our 38912) as stale, and would
// flag the live v1 rows for a "upgrade" that halves their memory hardness.
// What we mint and what we accept are different questions.
func weaker(memory, time uint32) bool {
return uint64(memory)*uint64(time) < minCost
}
// parse pulls the parameters, salt and key out of a PHC argon2id string:
//
// $argon2id$v=19$m=65536,t=1,p=2$<b64 salt>$<b64 key>
func parse(digest string) (memory, time uint32, threads uint8, salt, key []byte, err error) {
// A leading "$" makes the first field empty, hence 6.
parts := strings.Split(digest, "$")
if len(parts) != 6 || parts[1] != SchemeArgon2id {
return 0, 0, 0, nil, nil, errors.New("password: not an argon2id digest")
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return 0, 0, 0, nil, nil, errors.New("password: bad version field")
}
if version != argon2.Version {
return 0, 0, 0, nil, nil, fmt.Errorf("password: unsupported argon2 version %d", version)
}
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return 0, 0, 0, nil, nil, errors.New("password: bad parameter field")
}
// argon2.IDKey panics on a zero time or thread count; a hostile or corrupt
// digest must not be able to reach that.
if memory == 0 || time == 0 || threads == 0 {
return 0, 0, 0, nil, nil, errors.New("password: zero parameter")
}
if salt, err = base64.RawStdEncoding.DecodeString(parts[4]); err != nil {
return 0, 0, 0, nil, nil, errors.New("password: bad salt encoding")
}
if key, err = base64.RawStdEncoding.DecodeString(parts[5]); err != nil {
return 0, 0, 0, nil, nil, errors.New("password: bad key encoding")
}
if len(salt) == 0 || len(key) == 0 {
return 0, 0, 0, nil, nil, errors.New("password: empty salt or key")
}
return memory, time, threads, salt, key, nil
}
// idKey is argon2.IDKey under `gate`, which is the only reason the bound holds:
// every argon2id computation in the process — mint and verify — goes through
// here.
func idKey(plaintext, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte {
gate <- struct{}{}
defer func() { <-gate }()
return argon2.IDKey(plaintext, salt, time, memory, threads, keyLen)
}
+305
View File
@@ -0,0 +1,305 @@
// Copyright 2026 Hanzo AI, Inc. All rights reserved.
package password
import (
"encoding/base64"
"fmt"
"strings"
"testing"
)
// Golden digests minted by the EXACT code path that produced the live v1 rows:
//
// iam/cred/argon2id.go -> argon2id.CreateHash(pw, argon2id.DefaultParams)
// with github.com/alexedwards/argon2id v0.0.0-20211130144151-3585854a6387
// (that version's DefaultParams: m=64*1024, t=1, p=2, salt 16, key 32)
//
// They are byte-shaped exactly like the 85 argon2id rows measured in the live
// prod store on 2026-07-16 ($argon2id$v=19$m=65536,t=1,p=2$...) — verified by
// reading the parameter segment of the real rows.
//
// These are synthetic digests of known throwaway passwords, minted here for the
// test. No live credential material is reproduced, and none should ever be.
//
// This is the test that matters: iam2 must verify a digest MINTED BY SOMETHING
// ELSE. A round-trip of our own hasher passes happily while every real login is
// broken — which is exactly the state this package was written to fix.
const (
liveArgon2idDigest = "$argon2id$v=19$m=65536,t=1,p=2$pp/ox8H4VMz2MEVeKoOuxg$Vqb3kOJtdw9vdDMTvJG/yn8U81IwcuidSJFXMUaI+u0"
liveArgon2idPassword = "correct horse battery staple"
// A second vector, distinct password + salt, guarding against a fluke.
liveArgon2idDigest2 = "$argon2id$v=19$m=65536,t=1,p=2$6bXpDr3wTXMi+qXh4GNlRg$BmOi5GhRqpP0RT2cGZ3LHbfcXs37/jT8xP0rxhdmop4"
liveArgon2idPassword2 = "hunter2"
// bcrypt at the costs actually observed live ($2a$10 x24, $2b$10 x15,
// $2b$12 x1). $2b$ is the same digest with the minor version bumped: the
// $2a$/$2b$ difference is a wraparound fix that cannot affect a <=72-byte
// password, and x/crypto accepts any minor version.
liveBcrypt10Digest = "$2a$10$TD8./C3ff5vaeBHgUEfAW.55wo2O7e0RGGoXBOP1fv6mkQtUpVrv6"
liveBcrypt12Digest = "$2a$12$KovJZc28AmrlLpI5H.yuF.htTbrskZWpaNvBodi1eDUT6u1g1ylpa"
liveBcryptPassword = "hunter2"
)
// TestVerifiesLiveShapedArgon2idDigest is THE regression test for the blocker:
// bcrypt-only verify handed an argon2id PHC string returns ErrHashTooShort, so
// every argon2id account failed login. Against the pre-fix code this fails.
func TestVerifiesLiveShapedArgon2idDigest(t *testing.T) {
for _, tc := range []struct{ name, digest, password string }{
{"vector 1", liveArgon2idDigest, liveArgon2idPassword},
{"vector 2", liveArgon2idDigest2, liveArgon2idPassword2},
} {
t.Run(tc.name, func(t *testing.T) {
ok, stale := Verify(tc.digest, tc.password)
if !ok {
t.Fatal("a live-shaped v1 argon2id digest did not verify — every argon2id login is broken")
}
// m=65536,t=1 -> 65536 KiB-passes, above the 19456*2=38912 policy.
// Re-hashing these to policy would WEAKEN them; must not be stale.
if stale {
t.Fatal("live argon2id (m=65536,t=1,p=2) marked stale — re-hashing it to policy would weaken the account")
}
})
}
}
func TestRejectsWrongPasswordAgainstLiveDigest(t *testing.T) {
for _, wrong := range []string{"", "wrong", liveArgon2idPassword + "x", strings.ToUpper(liveArgon2idPassword)} {
if ok, _ := Verify(liveArgon2idDigest, wrong); ok {
t.Fatalf("wrong password %q verified against a live argon2id digest", wrong)
}
}
}
// TestVerifiesLiveBcryptDigest proves the 40 live bcrypt rows still log in —
// deleting the bcrypt path would have broken every one of them.
func TestVerifiesLiveBcryptDigest(t *testing.T) {
for _, tc := range []struct{ name, digest string }{
{"$2a$10 (24 live rows)", liveBcrypt10Digest},
{"$2a$12 (1 live row)", liveBcrypt12Digest},
// The 15 live $2b$10 rows: same digest, minor version bumped.
{"$2b$10 (15 live rows)", "$2b$" + strings.TrimPrefix(liveBcrypt10Digest, "$2a$")},
// Not observed live, but a bcrypt dialect we must not silently reject.
{"$2y$10", "$2y$" + strings.TrimPrefix(liveBcrypt10Digest, "$2a$")},
} {
t.Run(tc.name, func(t *testing.T) {
ok, stale := Verify(tc.digest, liveBcryptPassword)
if !ok {
t.Fatal("a live-shaped bcrypt digest did not verify — these accounts would be locked out")
}
// bcrypt is not what we mint: a successful login is the one moment
// we can retire it.
if !stale {
t.Fatal("bcrypt digest not marked stale — the 40 live bcrypt rows would never migrate")
}
})
}
if ok, _ := Verify(liveBcrypt10Digest, "wrong"); ok {
t.Fatal("wrong password verified against a live bcrypt digest")
}
}
// TestNeverStaleOnFailure: a failed guess must never trigger a re-hash.
func TestNeverStaleOnFailure(t *testing.T) {
for _, digest := range []string{liveArgon2idDigest, liveBcrypt10Digest} {
ok, stale := Verify(digest, "definitely-not-the-password")
if ok {
t.Fatal("wrong password verified")
}
if stale {
t.Fatal("stale reported for a FAILED verify — would re-hash on an attacker's guess")
}
}
}
func TestHashVerifyRoundTrip(t *testing.T) {
const pw = "a fresh password"
digest, err := Hash(pw)
if err != nil {
t.Fatalf("Hash: %v", err)
}
ok, stale := Verify(digest, pw)
if !ok {
t.Fatal("freshly minted digest did not verify")
}
if stale {
t.Fatal("freshly minted digest reported stale — every login would re-hash forever")
}
if ok, _ := Verify(digest, pw+"x"); ok {
t.Fatal("wrong password verified against a fresh digest")
}
}
// TestHashMintsCurrentPolicy pins the parameters we advertise. OWASP baseline:
// m=19456 (19 MiB), t=2, p=1.
func TestHashMintsCurrentPolicy(t *testing.T) {
digest, err := Hash("x")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if !strings.HasPrefix(digest, "$argon2id$v=19$m=19456,t=2,p=1$") {
t.Fatalf("Hash did not mint the OWASP baseline parameters: %s", paramsOf(digest))
}
memory, time, threads, salt, key, err := parse(digest)
if err != nil {
t.Fatalf("parse of our own digest: %v", err)
}
if memory != memoryKiB || time != timeCost || threads != lanes {
t.Fatalf("got m=%d,t=%d,p=%d want m=%d,t=%d,p=%d", memory, time, threads, memoryKiB, timeCost, lanes)
}
if len(salt) != saltLen {
t.Fatalf("salt length %d, want %d", len(salt), saltLen)
}
if len(key) != keyLen {
t.Fatalf("key length %d, want %d", len(key), keyLen)
}
}
// TestHashSaltIsRandom: two digests of the same password must differ.
func TestHashSaltIsRandom(t *testing.T) {
a, err := Hash("same password")
if err != nil {
t.Fatalf("Hash: %v", err)
}
b, err := Hash("same password")
if err != nil {
t.Fatalf("Hash: %v", err)
}
if a == b {
t.Fatal("two hashes of the same password are identical — the salt is not random")
}
}
// TestStaleTracksMemoryTimeProduct: the axis Argon2 parameters trade along.
// Every OWASP-equivalent set must be accepted (not re-hashed); anything
// genuinely cheaper than policy must be re-hashed.
func TestStaleTracksMemoryTimeProduct(t *testing.T) {
for _, tc := range []struct {
name string
memory, time uint32
wantStale bool
}{
// OWASP's five "equivalent" sets — none should be re-hashed.
{"OWASP m=47104,t=1", 47104, 1, false},
{"OWASP m=19456,t=2 (our policy)", 19456, 2, false},
{"OWASP m=12288,t=3", 12288, 3, false},
{"OWASP m=9216,t=4", 9216, 4, false},
{"OWASP m=7168,t=5", 7168, 5, false},
// The live v1 rows — stronger than policy, must not be downgraded.
{"live v1 m=65536,t=1", 65536, 1, false},
// Genuinely weaker than policy.
{"weak m=4096,t=1", 4096, 1, true},
{"weak m=1024,t=2", 1024, 2, true},
} {
t.Run(tc.name, func(t *testing.T) {
if got := weaker(tc.memory, tc.time); got != tc.wantStale {
t.Fatalf("weaker(m=%d,t=%d) = %v, want %v", tc.memory, tc.time, got, tc.wantStale)
}
})
}
}
// TestWeakArgon2idIsRehashed: an under-strength argon2id digest verifies AND is
// flagged for replacement.
func TestWeakArgon2idIsRehashed(t *testing.T) {
// Minted at deliberately weak parameters (m=1024,t=1,p=1).
const pw = "weakly hashed"
weak := mintAt(t, pw, 1024, 1, 1)
ok, stale := Verify(weak, pw)
if !ok {
t.Fatal("weak argon2id digest did not verify")
}
if !stale {
t.Fatal("weak argon2id digest not flagged for re-hash")
}
}
// TestMalformedDigestFailsClosed: no panic, no match. argon2.IDKey panics on a
// zero time/threads value, so a corrupt or hostile digest must never reach it.
func TestMalformedDigestFailsClosed(t *testing.T) {
for _, tc := range []struct{ name, digest string }{
{"empty", ""},
{"unknown scheme", "$scrypt$v=19$m=1,t=1,p=1$aaaa$bbbb"},
{"plaintext (no scheme)", "hunter2"},
{"bare marker", "$argon2id$"},
{"too few fields", "$argon2id$v=19$m=65536,t=1,p=2$onlysalt"},
{"too many fields", liveArgon2idDigest + "$extra"},
{"zero memory", "$argon2id$v=19$m=0,t=1,p=1$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"zero time", "$argon2id$v=19$m=65536,t=0,p=1$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"zero lanes", "$argon2id$v=19$m=65536,t=1,p=0$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"bad version", "$argon2id$v=16$m=65536,t=1,p=2$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"nonnumeric version", "$argon2id$v=xx$m=65536,t=1,p=2$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"missing params", "$argon2id$v=19$$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"garbage params", "$argon2id$v=19$m=a,t=b,p=c$cGFzc3dvcmQ$cGFzc3dvcmQ"},
{"bad salt base64", "$argon2id$v=19$m=65536,t=1,p=2$!!!!$cGFzc3dvcmQ"},
{"bad key base64", "$argon2id$v=19$m=65536,t=1,p=2$cGFzc3dvcmQ$!!!!"},
{"empty salt+key", "$argon2id$v=19$m=65536,t=1,p=2$$"},
{"truncated bcrypt", "$2a$10$tooshort"},
{"bcrypt garbage", "$2a$10$" + strings.Repeat("!", 53)},
{"argon2i (not id)", "$argon2i$v=19$m=65536,t=1,p=2$cGFzc3dvcmQ$cGFzc3dvcmQ"},
} {
t.Run(tc.name, func(t *testing.T) {
// A panic here is a crash on the unauthenticated login path.
ok, stale := Verify(tc.digest, "anything")
if ok {
t.Fatal("malformed digest verified — must fail closed")
}
if stale {
t.Fatal("malformed digest marked stale")
}
})
}
}
// TestVerifyHasNoLengthCap: maxPasswordLen bounds what we MINT. Enforcing it on
// the verify path would lock out any account whose password predates the bound.
func TestVerifyHasNoLengthCap(t *testing.T) {
long := strings.Repeat("x", maxPasswordLen*2)
digest := mintAt(t, long, 1024, 1, 1) // weak params: keep the test fast
if ok, _ := Verify(digest, long); !ok {
t.Fatal("an over-long password could not be verified — the cap must not reach the verify path")
}
}
func TestHashRejectsOverlongPassword(t *testing.T) {
if _, err := Hash(strings.Repeat("x", maxPasswordLen+1)); err != ErrPasswordTooLong {
t.Fatalf("Hash(overlong) error = %v, want ErrPasswordTooLong", err)
}
if _, err := Hash(strings.Repeat("x", maxPasswordLen)); err != nil {
t.Fatalf("Hash at exactly maxPasswordLen: %v", err)
}
}
// TestEmptyDigestNeverVerifies: the 63 live federated users hold an empty
// digest. An empty password must not authenticate them.
func TestEmptyDigestNeverVerifies(t *testing.T) {
for _, pw := range []string{"", "anything"} {
if ok, _ := Verify("", pw); ok {
t.Fatalf("empty digest verified against %q — federated accounts would be open", pw)
}
}
}
// mintAt builds an argon2id PHC digest at arbitrary parameters, for test
// fixtures that must not depend on current policy.
func mintAt(t *testing.T, plaintext string, memory, time uint32, threads uint8) string {
t.Helper()
salt := []byte("0123456789abcdef")
key := idKey([]byte(plaintext), salt, time, memory, threads, keyLen)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s", memory, time, threads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key),
)
}
// paramsOf returns just the scheme+parameter prefix of a digest — never the
// salt or key — so a failure message can name what went wrong without printing
// credential material.
func paramsOf(digest string) string {
parts := strings.Split(digest, "$")
if len(parts) < 4 {
return "<unparseable>"
}
return strings.Join(parts[:4], "$")
}
+18 -12
View File
@@ -10,11 +10,10 @@ import (
// User is an identity principal — the v2 form of the v1 Casdoor `user` row,
// re-expressed on hanzoai/orm. It is the authentication entity: the only
// credential material it persists is PasswordHash (a one-way bcrypt digest,
// json:"-" so it never leaves the process) and the legacy hash metadata used
// to migrate rows minted before the bcrypt cutover. The plaintext password is
// never a field on this struct — it arrives on the create/update request,
// is hashed immediately, and is discarded.
// credential material it persists is PasswordHash (a one-way digest) plus the
// metadata describing it. The plaintext password is never a field on this
// struct — it arrives on the create/update request, is hashed immediately, and
// is discarded.
//
// The natural key is (Owner, Name): Owner is the tenant/organization slug,
// Name is unique within it. The embedded orm.Model[User] supplies the storage
@@ -33,13 +32,20 @@ type User struct {
ExternalId string `json:"externalId,omitempty" orm:"index"`
Type string `json:"type,omitempty"`
// Credential material. PasswordHash is a one-way bcrypt digest and is
// verify-only. It MUST be persisted (orm serializes the entity to its JSON
// data column, so a json:"-" field would never be stored — that silently
// broke login), so it carries a real json tag; the users API redact() strips
// it (and every other secret) from every response. PasswordType and
// PasswordSalt describe the digest scheme so rows hashed under the legacy
// argon2id scheme can still be verified and lazily re-hashed to bcrypt.
// Credential material. PasswordHash is a one-way digest and is verify-only.
// It MUST be persisted (orm serializes the entity to its JSON data column,
// so a json:"-" field would never be stored — that silently broke login), so
// it carries a real json tag; the users API redact() strips it (and every
// other secret) from every response.
//
// The digest is self-describing: argon2id rows are a PHC string carrying
// their own parameters and salt, bcrypt rows carry theirs. internal/password
// reads the scheme from PasswordHash and never from PasswordType — so these
// two fields are DESCRIPTIVE only, and nothing authenticates on them.
// PasswordType is derived from the digest (password.Scheme) so it cannot
// contradict the bytes it names; PasswordSalt is a v1 legacy field that
// argon2id does not use (the salt is inside the PHC string) and is cleared
// when a row is re-minted.
PasswordHash string `json:"passwordHash,omitempty"`
PasswordType string `json:"passwordType,omitempty"`
PasswordSalt string `json:"passwordSalt,omitempty"`
+60 -27
View File
@@ -5,10 +5,15 @@
// by the (owner, name) natural key.
//
// This is the authentication entity, so the credential invariant is absolute:
// the plaintext password rides in on the create/update request, is hashed with
// bcrypt exactly once, and is discarded. Only the one-way digest reaches the
// store (schema.User.PasswordHash, json:"-"), and no response ever carries the
// digest or any other secret material — reads pass through redact() first.
// the plaintext password rides in on the create/update request, is hashed
// exactly once, and is discarded. Only the one-way digest reaches the store
// (schema.User.PasswordHash), and no response ever carries the digest or any
// other secret material — reads pass through redact() first.
//
// How a password is hashed or checked is not this package's business: it calls
// internal/password and holds no opinion of its own. That is deliberate — a
// second opinion about the algorithm is exactly how a row comes to claim one
// scheme while holding another.
package users
import (
@@ -17,11 +22,10 @@ import (
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/hanzoai/orm"
"github.com/zap-proto/zip"
"github.com/hanzoai/iam2/internal/password"
"github.com/hanzoai/iam2/internal/schema"
)
@@ -117,12 +121,12 @@ func (a *API) Create(ctx context.Context, in *CreateInput) (*schema.User, error)
u.PasswordHash, u.PasswordSalt = "", ""
u.PasswordType = ""
if in.Password != "" {
hash, err := hashPassword(in.Password)
hash, err := password.Hash(in.Password)
if err != nil {
return nil, zip.ErrInternal("hash password: " + err.Error())
}
u.PasswordHash = hash
u.PasswordType = "bcrypt"
u.PasswordType = password.Scheme(hash)
}
now := nowRFC3339()
u.CreatedTime, u.UpdatedTime = now, now
@@ -200,12 +204,12 @@ func (a *API) Update(ctx context.Context, in *UpdateInput) (*schema.User, error)
u.PasswordType = existing.PasswordType
u.PasswordSalt = existing.PasswordSalt
if in.Password != "" {
hash, err := hashPassword(in.Password)
hash, err := password.Hash(in.Password)
if err != nil {
return nil, zip.ErrInternal("hash password: " + err.Error())
}
u.PasswordHash = hash
u.PasswordType = "bcrypt"
u.PasswordType = password.Scheme(hash)
u.PasswordSalt = ""
}
@@ -249,23 +253,48 @@ func (a *API) lookup(ctx context.Context, owner, name string) (*schema.User, err
return u, nil
}
// hashPassword derives a one-way bcrypt digest from a plaintext password.
func hashPassword(plaintext string) (string, error) {
b, err := bcrypt.GenerateFromPassword([]byte(plaintext), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(b), nil
}
// VerifyPassword reports whether plaintext matches the user's stored digest.
// It is the single verify choke point for the login path — the digest itself
// never leaves the store, so verification happens here, against the row.
func VerifyPassword(u *schema.User, plaintext string) bool {
if u == nil || u.PasswordHash == "" {
// VerifyPassword reports whether plaintext matches the user's stored digest,
// and transparently re-mints that digest when it is stale. It is the single
// verify choke point for the login path — the digest itself never leaves the
// store, so verification happens here, against the row.
//
// The algorithm is not decided here. password.Verify reads it out of the stored
// digest, which is the only description of the digest that cannot be wrong.
func VerifyPassword(ctx context.Context, db orm.DB, u *schema.User, plaintext string) bool {
if u == nil {
return false
}
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(plaintext)) == nil
ok, stale := password.Verify(u.PasswordHash, plaintext)
if !ok {
return false
}
if stale {
upgrade(ctx, db, u, plaintext)
}
return true
}
// upgrade re-mints a stale digest at current parameters. A successful login is
// the only moment the plaintext exists to re-hash from, so it is the only
// moment a legacy row can be retired — the alternative is a forced reset for
// every account that has not changed its password.
//
// Best-effort by construction: the password has already been proven correct, so
// a storage failure here must not fail the login. It costs one more login to
// try again.
func upgrade(ctx context.Context, db orm.DB, u *schema.User, plaintext string) {
hash, err := password.Hash(plaintext)
if err != nil {
return
}
u.PasswordHash = hash
u.PasswordType = password.Scheme(hash)
// The legacy per-row salt is meaningless under argon2id — the salt lives
// inside the PHC string. Leaving it behind would strand a value that
// describes nothing.
u.PasswordSalt = ""
u.Init(db)
_ = u.UpdateCtx(ctx)
}
// view redacts u in place and returns it, ready to serialize.
@@ -275,8 +304,12 @@ func view(u *schema.User) *schema.User {
}
// redact strips every secret/bearer field from a user before it is returned.
// PasswordHash and AccessSecretHash are already json:"-"; this zeros the
// remaining sensitive material for defense in depth.
//
// This is the ONLY thing keeping the digest out of a response — not defense in
// depth. PasswordHash and AccessSecretHash carry real json tags on purpose (orm
// serializes the entity to its JSON data column, so json:"-" would mean "never
// stored" — that silently broke login once already), which means they serialize
// into a response unless zeroed here. Every read path must go through view().
func redact(u *schema.User) {
u.PasswordHash = ""
u.PasswordSalt = ""