package service import ( "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "strings" "golang.org/x/crypto/argon2" ) // argon2id parameters: RFC 9106's second recommended profile (m=64 MiB, t=3, // p=4) — the memory-constrained option, appropriate for a self-hosted box where // the 2 GiB first profile is too heavy. They are encoded into every stored hash, // so raising them later leaves old hashes verifiable. const ( argonMemKiB = 64 * 1024 // 64 MiB argonTime = 3 argonThreads = 4 argonKeyLen = 32 argonSaltLen = 16 ) // errBadHash marks a stored hash string that could not be parsed — a data or // programming error, not a wrong password. Callers treat it as an auth failure // and log it (Login does). var errBadHash = errors.New("service: malformed password hash") // b64 is the padding-free base64 used inside the PHC-style hash string. var b64 = base64.RawStdEncoding // timingHash is a valid argon2id hash used to equalize login response time when // an email is unknown (see Service.dummyHash). It uses a fixed salt rather than // crypto/rand so it can never fail to be produced at startup — an all-important // property, since a missing timing hash would silently re-open account // enumeration. It guards nothing, so a static salt is safe; deriving it from the // live argon parameters keeps its cost matched to a real verify. func timingHash() string { salt := []byte("pansy-timing-eq!") // exactly argonSaltLen (16) bytes key := argon2.IDKey([]byte("x"), salt, argonTime, argonMemKiB, argonThreads, argonKeyLen) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemKiB, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key), ) } // hashPassword returns a self-describing argon2id hash in the PHC string format // "$argon2id$v=19$m=...,t=...,p=...$salt$hash" (all base64, no padding). func hashPassword(password string) (string, error) { salt := make([]byte, argonSaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("service: generate salt: %w", err) } key := argon2.IDKey([]byte(password), salt, argonTime, argonMemKiB, argonThreads, argonKeyLen) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemKiB, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key), ), nil } // verifyPassword reports whether password matches the encoded argon2id hash. The // comparison is constant-time. A malformed encoded value returns errBadHash. func verifyPassword(encoded, password string) (bool, error) { mem, t, threads, salt, want, err := decodeHash(encoded) if err != nil { return false, err } got := argon2.IDKey([]byte(password), salt, t, mem, threads, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1, nil } // decodeHash parses a PHC-format argon2id string into its parameters, salt, and // derived key. Any structural problem returns errBadHash (with zero values) so // the caller never has to distinguish parse failures. func decodeHash(encoded string) (mem, t uint32, threads uint8, salt, key []byte, err error) { fail := func() (uint32, uint32, uint8, []byte, []byte, error) { return 0, 0, 0, nil, nil, errBadHash } parts := strings.Split(encoded, "$") // ["", "argon2id", "v=19", "m=..,t=..,p=..", "", ""] if len(parts) != 6 || parts[1] != "argon2id" { return fail() } var version int if _, e := fmt.Sscanf(parts[2], "v=%d", &version); e != nil || version != argon2.Version { return fail() } if _, e := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &mem, &t, &threads); e != nil { return fail() } if salt, err = b64.DecodeString(parts[4]); err != nil { return fail() } if key, err = b64.DecodeString(parts[5]); err != nil || len(key) == 0 { return fail() } return mem, t, threads, salt, key, nil }