Compare commits
5
Commits
main
...
feat/argon2id
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c23df0600c | ||
|
|
d13582f5df | ||
|
|
6fe1cfe9dc | ||
|
|
ecaef9f4f0 | ||
|
|
24927d18c5 |
+91
-15
@@ -40,21 +40,97 @@ Phases 0–4 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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user