package admin import ( "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/subtle" "encoding/base64" "errors" "fmt" "strings" "golang.org/x/crypto/argon2" ) // Argon2id parameters. These follow OWASP's "second" recommended profile // (64 MiB, 1 iteration, parallelism 4) — strong yet fast enough for an // interactive admin login. const ( argonMemory = 64 * 1024 // KiB argonTime = 1 argonParallelism = 4 argonSaltLen = 16 argonKeyLen = 32 ) var b64 = base64.RawStdEncoding // HashPassword hashes a plaintext password with argon2id and returns a PHC // formatted string: $argon2id$v=19$m=...,t=...,p=...$$. func HashPassword(password string) (string, error) { salt := make([]byte, argonSaltLen) if _, err := rand.Read(salt); err != nil { return "", fmt.Errorf("admin.HashPassword: %w", err) } key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonParallelism, argonKeyLen) return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonParallelism, b64.EncodeToString(salt), b64.EncodeToString(key)), nil } // VerifyPassword reports whether password matches the given PHC-encoded // argon2id hash, in constant time. func VerifyPassword(encoded, password string) bool { params, salt, want, err := decodePHC(encoded) if err != nil { return false } got := argon2.IDKey([]byte(password), salt, params.t, params.m, params.p, uint32(len(want))) return subtle.ConstantTimeCompare(got, want) == 1 } type argonParams struct { m uint32 t uint32 p uint8 } func decodePHC(encoded string) (argonParams, []byte, []byte, error) { parts := strings.Split(encoded, "$") // ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash] if len(parts) != 6 || parts[1] != "argon2id" { return argonParams{}, nil, nil, errors.New("admin: malformed argon2 hash") } var version int if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil { return argonParams{}, nil, nil, errors.New("admin: bad argon2 version") } var pr argonParams if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &pr.m, &pr.t, &pr.p); err != nil { return argonParams{}, nil, nil, errors.New("admin: bad argon2 params") } salt, err := b64.DecodeString(parts[4]) if err != nil { return argonParams{}, nil, nil, errors.New("admin: bad argon2 salt") } hash, err := b64.DecodeString(parts[5]) if err != nil { return argonParams{}, nil, nil, errors.New("admin: bad argon2 hash") } return pr, salt, hash, nil } // EncryptSecret seals plaintext with AES-256-GCM under key (32 bytes). The // returned blob is nonce || ciphertext, suitable for storing in a VARBINARY // column. Used to keep TOTP secrets encrypted at rest. func EncryptSecret(key []byte, plaintext string) ([]byte, error) { gcm, err := newGCM(key) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := rand.Read(nonce); err != nil { return nil, fmt.Errorf("admin.EncryptSecret nonce: %w", err) } return gcm.Seal(nonce, nonce, []byte(plaintext), nil), nil } // DecryptSecret reverses EncryptSecret. func DecryptSecret(key, blob []byte) (string, error) { gcm, err := newGCM(key) if err != nil { return "", err } ns := gcm.NonceSize() if len(blob) < ns { return "", errors.New("admin.DecryptSecret: ciphertext too short") } nonce, ct := blob[:ns], blob[ns:] pt, err := gcm.Open(nil, nonce, ct, nil) if err != nil { return "", fmt.Errorf("admin.DecryptSecret: %w", err) } return string(pt), nil } func newGCM(key []byte) (cipher.AEAD, error) { if len(key) != 32 { return nil, fmt.Errorf("admin: secret key must be 32 bytes, got %d", len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("admin: aes cipher: %w", err) } return cipher.NewGCM(block) }