Skip to content

Commit 9e8df9a

Browse files
committed
feat: add password policies and tighten argon2id defaults
- introduce Policy presets, WithPolicy option, and shared argon2 policy params - gate argon2id hashing behind parameter validation and document the new API - expand policy-focused tests plus README/CHANGELOG updates describing the Argon2id-only stance - refresh README + CHANGELOG for v0.2.0 release
1 parent 2b19255 commit 9e8df9a

8 files changed

Lines changed: 249 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,13 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## v0.2.0 - 2026-01-17
6+
- Added policy presets (`PolicyInteractive`, `PolicyModerate`, `PolicySensitive`) plus the `WithPolicy` option for configuring Argon2id without manual parameters.
7+
- Introduced `argon2.ParamsForPolicy` and a shared policy descriptor so the CLI and library reuse the same vetted defaults.
8+
- Hardened `argon2.Argon2idHasher` with parameter validation guards and augmented doc comments across the new public surface.
9+
- Expanded the test suite to cover policy selection and the Argon2 preset helpers, keeping `go test ./...` green.
10+
- Rewrote README to state the Argon2id-only stance and document the policy workflow.
11+
512
## v0.1.0 - 2026-01-17
613
- Initial public release of `go-pwdhash` with Argon2id defaults (64MiB memory, 3 iterations, 4 lanes, 16-byte salts, 32-byte keys).
714
- Introduced PHC-compliant encoder/decoder plus constant-time comparison helpers.

README.md

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
# go-pwdhash
22

3-
`go-pwdhash` is a Go-first implementation of the Password Hashing Competition (PHC) string format with a batteries-included Argon2id hasher, deterministic upgrade signals, and zero surprises for callers who need predictable password hygiene.
3+
`go-pwdhash` is a Go-first password hashing helper that embraces the PHC (Password Hashing Competition) format, wraps Argon2id with safe defaults, and surfaces a minimal API for hashing, verification, and upgrades.
4+
5+
## Argon2id Only
6+
7+
pwdhash intentionally supports **Argon2id only**. Algorithms that have already been superseded by Argon2id will not be added, reducing the chance of accidentally selecting outdated primitives. If a superior successor to Argon2id emerges, pwdhash will adopt it behind the same API surface.
8+
9+
## Password Policies
10+
11+
pwdhash ships with opinionated Argon2id policies so applications can select a strength profile without touching raw parameters:
12+
13+
- **Interactive** – user login flows where latency matters most.
14+
- **Moderate** – API keys, service-to-service calls, and privileged automation.
15+
- **Sensitive** – infrastructure secrets, root accounts, and long-lived credentials.
16+
17+
Policies prevent insecure configurations by clamping the underlying Argon2id memory, iteration, and parallelism values to vetted presets.
418

519
## Highlights
620

7-
- **Modern Argon2id defaults** – ships with 64MiB memory, 3 iterations, 4 lanes, 16-byte salts, 32-byte keys, and Argon2 v=19 metadata.
8-
- **PHC compliant outputs** – hashes look like `$argon2id$v=19$...` and round-trip cleanly through the built-in parser.
9-
- **Interoperable by design** – encoded hashes verify inside Python's `pwdlib` and equivalent implementations in Rust or C without adapters.
10-
- **Extensible registry** – inject alternative hashers (or tuned Argon2id instances) via options while keeping a single entry point.
11-
- **Deterministic lifecycle**`Hash`, `Verify`, and `NeedsRehash` expose the minimum API you need to manage password upgrades.
12-
- **Constant-time comparisons** – verification uses `crypto/subtle` helpers to avoid timing leaks.
21+
- **PHC-compliant output** – hashes look like `$argon2id$v=19$...` and parse cleanly across ecosystems.
22+
- **Deterministic upgrade path**`NeedsRehash` compares stored parameters to the active policy so callers know exactly when to re-encode.
23+
- **Extensible registry** – advanced users may inject tuned Argon2id instances or alternate hashers via the option system.
24+
- **Constant-time verification** – comparisons use helpers under `internal/subtle` to avoid timing leaks.
1325

1426
## Installation
1527

1628
```bash
1729
go get github.com/allisson/go-pwdhash
1830
```
1931

20-
The module targets Go 1.24, depends on `golang.org/x/crypto`, and uses `stretchr/testify` only for tests.
32+
The module targets Go 1.24, depends on `golang.org/x/crypto`, and uses `stretchr/testify` solely for tests.
2133

2234
## Quick Start
2335

@@ -31,7 +43,9 @@ import (
3143
)
3244

3345
func main() {
34-
hasher, err := pwdhash.New()
46+
hasher, err := pwdhash.New(
47+
pwdhash.WithPolicy(pwdhash.PolicyInteractive),
48+
)
3549
if err != nil {
3650
panic(err)
3751
}
@@ -57,7 +71,12 @@ func main() {
5771

5872
## Configuration
5973

60-
`pwdhash.New` accepts functional options. By default it registers a single Argon2id hasher returned by `argon2.Default()`. To tune parameters, construct the hasher yourself and inject it:
74+
`pwdhash.New` accepts functional options:
75+
76+
- `pwdhash.WithPolicy` selects one of the built-in presets.
77+
- `pwdhash.WithHasher` installs a custom `pwdhash.Hasher` (useful for bespoke Argon2id tuning or for experimenting with future algorithms).
78+
79+
Example of injecting custom parameters:
6180

6281
```go
6382
import (
@@ -78,30 +97,23 @@ func tunedHasher() (*pwdhash.PasswordHasher, error) {
7897
}
7998
```
8099

81-
To introduce a new algorithm, implement the `pwdhash.Hasher` interface and register it through `WithHasher`. The password hasher keeps an internal registry keyed by `Hasher.ID()`, so mixed fleets of algorithms are possible during migrations.
82-
83-
## PHC Encoding Basics
100+
## PHC Encoding
84101

85-
Internally, the library serializes `encoding.EncodedHash` structures that follow the pattern:
102+
pwdhash serializes `encoding.EncodedHash` values using the canonical PHC layout:
86103

87104
```
88105
$argon2id$v=19$m=65536,t=3,p=4$<base64(salt)>$<base64(hash)>
89106
```
90107

91-
- Parameters are stored verbatim; `NeedsRehash` compares them to the current configuration to decide when to upgrade.
92-
- The parser validates the prefix, version, parameter key/value pairs, and base64 payloads, returning structured errors for callers.
93-
94-
Because the format matches the PHC specification byte-for-byte, it remains compatible with Python, Rust, or C libraries that speak the same dialect.
108+
The parser validates algorithm identifiers, versions, parameter pairs, and Base64 payloads before handing them to the active hasher.
95109

96110
## Testing & Tooling
97111

98-
Run the suite locally:
99-
100112
```bash
101113
go test ./...
102114
```
103115

104-
Automation-friendly targets live in the Makefile:
116+
Or use the convenience targets:
105117

106118
```bash
107119
make lint # golangci-lint run -v --fix
@@ -112,9 +124,9 @@ make test # go test -covermode=count -coverprofile=count.out -v ./...
112124

113125
1. Fork and clone the repo.
114126
2. Run `go test ./...` (and `make lint`) before sending patches.
115-
3. Keep exports documented, stick to `gofmt` / `goimports`, and prefer table-driven tests.
116-
4. Discuss larger API changes via issues or draft PRs—Argon2 is the default focus, so new algorithms should include rationale and tests.
127+
3. Keep exports documented, prefer table-driven tests, and stick to `gofmt`/`goimports`.
128+
4. Argon2id is the focus; proposals for new algorithms should include rationale plus end-to-end tests.
117129

118130
## License
119131

120-
MIT licensed. See `LICENSE` for full text.
132+
MIT licensed. See `LICENSE` for details.

argon2/argon2id.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ func (a *Argon2idHasher) ID() string {
4040

4141
// Hash derives an Argon2id key and returns the PHC string.
4242
func (a *Argon2idHasher) Hash(password []byte) (string, error) {
43+
if err := a.validate(); err != nil {
44+
return "", err
45+
}
46+
4347
salt := make([]byte, a.SaltLength)
4448
if _, err := rand.Read(salt); err != nil {
4549
return "", err
@@ -71,6 +75,10 @@ func (a *Argon2idHasher) Hash(password []byte) (string, error) {
7175

7276
// Verify recomputes the Argon2id hash and compares it in constant time.
7377
func (a *Argon2idHasher) Verify(password []byte, encoded string) (bool, error) {
78+
if err := a.validate(); err != nil {
79+
return false, err
80+
}
81+
7482
parsed, err := encoding.Parse(encoded)
7583
if err != nil {
7684
return false, err
@@ -137,3 +145,16 @@ func (a *Argon2idHasher) NeedsRehash(encoded string) (bool, error) {
137145

138146
return false, nil
139147
}
148+
149+
func (a *Argon2idHasher) validate() error {
150+
if a.Memory < 32*1024 {
151+
return fmt.Errorf("argon2 memory too low")
152+
}
153+
if a.Iterations < 2 {
154+
return fmt.Errorf("argon2 iterations too low")
155+
}
156+
if a.Parallelism < 1 {
157+
return fmt.Errorf("argon2 parallelism too low")
158+
}
159+
return nil
160+
}

argon2/argon2id_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,69 @@ func newTestHasher() *Argon2idHasher {
1818
}
1919
}
2020

21+
func TestParamsForPolicy(t *testing.T) {
22+
t.Parallel()
23+
24+
tests := []struct {
25+
name string
26+
policy int
27+
want PolicyParams
28+
ok bool
29+
}{
30+
{
31+
name: "interactive",
32+
policy: 0,
33+
want: PolicyParams{
34+
Memory: 64 * 1024,
35+
Iterations: 3,
36+
Parallelism: 4,
37+
},
38+
ok: true,
39+
},
40+
{
41+
name: "moderate",
42+
policy: 1,
43+
want: PolicyParams{
44+
Memory: 128 * 1024,
45+
Iterations: 4,
46+
Parallelism: 4,
47+
},
48+
ok: true,
49+
},
50+
{
51+
name: "sensitive",
52+
policy: 2,
53+
want: PolicyParams{
54+
Memory: 256 * 1024,
55+
Iterations: 5,
56+
Parallelism: 8,
57+
},
58+
ok: true,
59+
},
60+
{
61+
name: "unknown",
62+
policy: 3,
63+
want: PolicyParams{},
64+
ok: false,
65+
},
66+
}
67+
68+
for _, tt := range tests {
69+
t.Run(tt.name, func(t *testing.T) {
70+
t.Parallel()
71+
72+
params, err := ParamsForPolicy(tt.policy)
73+
if tt.ok {
74+
require.NoError(t, err)
75+
require.Equal(t, tt.want, params)
76+
return
77+
}
78+
79+
require.Error(t, err)
80+
})
81+
}
82+
}
83+
2184
func TestArgon2idHasher_HashAndVerify(t *testing.T) {
2285
hasher := newTestHasher()
2386

argon2/policy.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package argon2
2+
3+
import "errors"
4+
5+
// PolicyParams captures the Argon2 tuning knobs for a policy preset.
6+
type PolicyParams struct {
7+
Memory uint32
8+
Iterations uint32
9+
Parallelism uint8
10+
}
11+
12+
// ParamsForPolicy returns the Argon2 parameters associated with the policy id.
13+
func ParamsForPolicy(p int) (PolicyParams, error) {
14+
switch p {
15+
case 0: // Interactive
16+
return PolicyParams{
17+
Memory: 64 * 1024, // 64 MB
18+
Iterations: 3,
19+
Parallelism: 4,
20+
}, nil
21+
22+
case 1: // Moderate
23+
return PolicyParams{
24+
Memory: 128 * 1024, // 128 MB
25+
Iterations: 4,
26+
Parallelism: 4,
27+
}, nil
28+
29+
case 2: // Sensitive
30+
return PolicyParams{
31+
Memory: 256 * 1024, // 256 MB
32+
Iterations: 5,
33+
Parallelism: 8,
34+
}, nil
35+
}
36+
37+
return PolicyParams{}, errors.New("unknown password policy")
38+
}

options.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,21 @@ func WithHasher(h Hasher) Option {
2626
c.current = h
2727
}
2828
}
29+
30+
// WithPolicy selects a preset Argon2id configuration for the PasswordHasher.
31+
func WithPolicy(p Policy) Option {
32+
return func(c *config) {
33+
params, err := argon2.ParamsForPolicy(int(p))
34+
if err != nil {
35+
panic(err) // invalid policy is a programming bug
36+
}
37+
38+
c.current = &argon2.Argon2idHasher{
39+
Memory: params.Memory,
40+
Iterations: params.Iterations,
41+
Parallelism: params.Parallelism,
42+
SaltLength: 16,
43+
KeyLength: 32,
44+
}
45+
}
46+
}

password_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,59 @@ func TestPasswordHasher_NeedsRehashDelegates(t *testing.T) {
7373
require.True(t, needs)
7474
}
7575

76+
func TestPasswordHasher_WithPolicy(t *testing.T) {
77+
t.Parallel()
78+
79+
tests := []struct {
80+
name string
81+
policy Policy
82+
check func(t *testing.T, hasher *argon2.Argon2idHasher)
83+
}{
84+
{
85+
name: "interactive",
86+
policy: PolicyInteractive,
87+
check: func(t *testing.T, hasher *argon2.Argon2idHasher) {
88+
require.EqualValues(t, 64*1024, hasher.Memory)
89+
require.EqualValues(t, 3, hasher.Iterations)
90+
require.EqualValues(t, 4, hasher.Parallelism)
91+
},
92+
},
93+
{
94+
name: "moderate",
95+
policy: PolicyModerate,
96+
check: func(t *testing.T, hasher *argon2.Argon2idHasher) {
97+
require.EqualValues(t, 128*1024, hasher.Memory)
98+
require.EqualValues(t, 4, hasher.Iterations)
99+
require.EqualValues(t, 4, hasher.Parallelism)
100+
},
101+
},
102+
{
103+
name: "sensitive",
104+
policy: PolicySensitive,
105+
check: func(t *testing.T, hasher *argon2.Argon2idHasher) {
106+
require.EqualValues(t, 256*1024, hasher.Memory)
107+
require.EqualValues(t, 5, hasher.Iterations)
108+
require.EqualValues(t, 8, hasher.Parallelism)
109+
},
110+
},
111+
}
112+
113+
for _, tt := range tests {
114+
tt := tt
115+
t.Run(tt.name, func(t *testing.T) {
116+
t.Parallel()
117+
118+
ph, err := New(WithPolicy(tt.policy))
119+
require.NoError(t, err)
120+
121+
h, ok := ph.registry["argon2id"].(*argon2.Argon2idHasher)
122+
require.True(t, ok)
123+
124+
tt.check(t, h)
125+
})
126+
}
127+
}
128+
76129
func TestNewSetsDefaultHasher(t *testing.T) {
77130
ph, err := New()
78131
require.NoError(t, err)

policy.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package pwdhash
2+
3+
// Policy represents a password hashing strength preset.
4+
type Policy int
5+
6+
const (
7+
// PolicyInteractive balances CPU cost with low latency for login flows.
8+
PolicyInteractive Policy = iota
9+
// PolicyModerate increases cost for privileged accounts or admin portals.
10+
PolicyModerate
11+
// PolicySensitive maximizes cost for high-value secrets.
12+
PolicySensitive
13+
)

0 commit comments

Comments
 (0)