Go · 5649 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package password implements argon2id password hashing with PHC-string
4 // encoding. argon2id is the OWASP-recommended scheme: memory-hard,
5 // parallelism-tunable, and the PHC-format winner.
6 //
7 // Output format (matches the canonical PHC string):
8 //
9 // $argon2id$v=19$m=65536,t=3,p=2$<saltB64>$<hashB64>
10 //
11 // The b64 encoding is RFC4648 standard b64 *without* padding, per the PHC
12 // spec. Hashes produced here are forward-compatible: parameters travel
13 // inside the string so callers can rotate Defaults() without losing the
14 // ability to verify older hashes.
15 package password
16
17 import (
18 "crypto/rand"
19 "crypto/subtle"
20 "encoding/base64"
21 "errors"
22 "fmt"
23 "strings"
24
25 "golang.org/x/crypto/argon2"
26 )
27
28 // Algo is the password_algo column value for hashes produced by this
29 // package. Lets the DB carry an explicit identifier so we can roll
30 // forward to a different algorithm later without ambiguity.
31 const Algo = "argon2id-v1"
32
33 // Params controls argon2id cost. Defaults() values are tuned to take
34 // roughly 100–300ms on dev hardware (Apple M-series) and a similar
35 // envelope on the production droplet (modern x86, 4 vCPU). Operators can
36 // override via auth.argon2.* config.
37 type Params struct {
38 Memory uint32 // KiB
39 Time uint32 // iterations
40 Threads uint8 // parallelism
41 SaltLen uint32 // bytes
42 KeyLen uint32 // bytes
43 }
44
45 // Defaults returns the OWASP-recommended baseline. ~64 MiB memory,
46 // 3 iterations, 2 lanes — empirically ~100–300 ms on dev hardware.
47 func Defaults() Params {
48 return Params{
49 Memory: 64 * 1024,
50 Time: 3,
51 Threads: 2,
52 SaltLen: 16,
53 KeyLen: 32,
54 }
55 }
56
57 // MinPasswordLength is the absolute floor enforced at the lowest layer.
58 // Higher layers (signup form) MAY enforce additional rules (common-password
59 // list, etc.) but MUST NOT relax this minimum.
60 const MinPasswordLength = 10
61
62 // Hash produces a PHC-encoded argon2id string for password using p.
63 // Returns an error when password is shorter than MinPasswordLength so
64 // the policy floor cannot be bypassed by a misbehaving caller.
65 func Hash(password string, p Params) (string, error) {
66 if len(password) < MinPasswordLength {
67 return "", fmt.Errorf("password: minimum %d characters", MinPasswordLength)
68 }
69 salt := make([]byte, p.SaltLen)
70 if _, err := rand.Read(salt); err != nil {
71 return "", fmt.Errorf("password: salt: %w", err)
72 }
73 key := argon2.IDKey([]byte(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen)
74 return encode(p, salt, key), nil
75 }
76
77 // Verify reports whether password matches the PHC-encoded hash.
78 // Returns false (without error) for a mismatching password and an error
79 // for a malformed hash.
80 func Verify(password, encoded string) (bool, error) {
81 p, salt, want, err := decode(encoded)
82 if err != nil {
83 return false, err
84 }
85 got := argon2.IDKey([]byte(password), salt, p.Time, p.Memory, p.Threads, p.KeyLen)
86 return subtle.ConstantTimeCompare(got, want) == 1, nil
87 }
88
89 // dummyEncoded is a fixed, valid hash used by login handlers to avoid
90 // timing leaks: when a username doesn't exist, callers still call Verify
91 // against this so the request takes the same time as a real failed login.
92 // The "password" used to derive it is intentionally unguessable.
93 var dummyEncoded string
94
95 // MustGenerateDummy generates the dummy hash on first call. Safe to call
96 // during init in test code; production wires this from server start so
97 // the hash matches the configured Params.
98 func MustGenerateDummy(p Params) {
99 enc, err := Hash("dummy-not-a-real-secret-not-a-real-secret", p)
100 if err != nil {
101 panic(fmt.Errorf("password: dummy: %w", err))
102 }
103 dummyEncoded = enc
104 }
105
106 // VerifyAgainstDummy runs Verify against the pre-computed dummy hash.
107 // Used by login handlers when the user lookup fails so the response time
108 // matches a real Verify call.
109 func VerifyAgainstDummy(password string) {
110 if dummyEncoded == "" {
111 MustGenerateDummy(Defaults())
112 }
113 _, _ = Verify(password, dummyEncoded)
114 }
115
116 func encode(p Params, salt, key []byte) string {
117 b := base64.RawStdEncoding
118 return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
119 argon2.Version, p.Memory, p.Time, p.Threads,
120 b.EncodeToString(salt), b.EncodeToString(key))
121 }
122
123 // decode parses a PHC argon2id string. Rejects unknown algorithms and
124 // versions so a future swap is forced through this package.
125 func decode(s string) (Params, []byte, []byte, error) {
126 parts := strings.Split(s, "$")
127 if len(parts) != 6 || parts[0] != "" {
128 return Params{}, nil, nil, errors.New("password: malformed PHC string")
129 }
130 if parts[1] != "argon2id" {
131 return Params{}, nil, nil, fmt.Errorf("password: unsupported algo %q", parts[1])
132 }
133 var version int
134 if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
135 return Params{}, nil, nil, fmt.Errorf("password: version: %w", err)
136 }
137 if version != argon2.Version {
138 return Params{}, nil, nil, fmt.Errorf("password: argon2 version mismatch: got %d, want %d", version, argon2.Version)
139 }
140 var p Params
141 if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Time, &p.Threads); err != nil {
142 return Params{}, nil, nil, fmt.Errorf("password: params: %w", err)
143 }
144 salt, err := base64.RawStdEncoding.DecodeString(parts[4])
145 if err != nil {
146 return Params{}, nil, nil, fmt.Errorf("password: salt: %w", err)
147 }
148 key, err := base64.RawStdEncoding.DecodeString(parts[5])
149 if err != nil {
150 return Params{}, nil, nil, fmt.Errorf("password: key: %w", err)
151 }
152 //nolint:gosec // G115: lengths are bounded by encoded byte slices (max ~1KB total).
153 p.SaltLen = uint32(len(salt))
154 //nolint:gosec // G115: see above.
155 p.KeyLen = uint32(len(key))
156 return p, salt, key, nil
157 }
158