checkpoint mciassrv
This commit is contained in:
250
internal/auth/auth.go
Normal file
250
internal/auth/auth.go
Normal file
@@ -0,0 +1,250 @@
|
||||
// Package auth implements login, TOTP verification, and credential management.
|
||||
//
|
||||
// Security design:
|
||||
// - All credential comparisons use constant-time operations to resist timing
|
||||
// side-channels. crypto/subtle.ConstantTimeCompare is used wherever secrets
|
||||
// are compared.
|
||||
// - On any login failure the error returned to the caller is always generic
|
||||
// ("invalid credentials"), regardless of which step failed, to prevent
|
||||
// user enumeration.
|
||||
// - TOTP uses a ±1 time-step window (±30s) per RFC 6238 recommendation.
|
||||
// - PHC string format is used for password hashes, enabling transparent
|
||||
// parameter upgrades without re-migration.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1" //nolint:gosec // SHA-1 is required by RFC 6238 for TOTP; not used for collision resistance.
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
encodingbase64 "encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"git.wntrmute.dev/kyle/mcias/internal/crypto"
|
||||
)
|
||||
|
||||
// ErrInvalidCredentials is returned for any authentication failure.
|
||||
// It intentionally does not distinguish between wrong password, wrong TOTP,
|
||||
// or unknown user — to prevent information leakage to the caller.
|
||||
var ErrInvalidCredentials = errors.New("auth: invalid credentials")
|
||||
|
||||
// ArgonParams holds Argon2id hashing parameters embedded in PHC strings.
|
||||
type ArgonParams struct {
|
||||
Time uint32
|
||||
Memory uint32 // KiB
|
||||
Threads uint8
|
||||
}
|
||||
|
||||
// DefaultArgonParams returns OWASP-2023-compliant parameters.
|
||||
// Security: These meet the OWASP minimum (time=2, memory=64MiB) and provide
|
||||
// additional margin with time=3.
|
||||
func DefaultArgonParams() ArgonParams {
|
||||
return ArgonParams{
|
||||
Time: 3,
|
||||
Memory: 64 * 1024, // 64 MiB in KiB
|
||||
Threads: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using Argon2id and returns a PHC-format string.
|
||||
// A random 16-byte salt is generated via crypto/rand for each call.
|
||||
//
|
||||
// Security: Argon2id is selected per OWASP recommendation; it resists both
|
||||
// side-channel and GPU brute-force attacks. The random salt ensures each hash
|
||||
// is unique even for identical passwords.
|
||||
func HashPassword(password string, params ArgonParams) (string, error) {
|
||||
if password == "" {
|
||||
return "", errors.New("auth: password must not be empty")
|
||||
}
|
||||
|
||||
// Generate a cryptographically-random 16-byte salt.
|
||||
salt, err := crypto.RandomBytes(16)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auth: generate salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
params.Time,
|
||||
params.Memory,
|
||||
params.Threads,
|
||||
32, // 256-bit output
|
||||
)
|
||||
|
||||
// PHC format: $argon2id$v=19$m=<M>,t=<T>,p=<P>$<salt-b64>$<hash-b64>
|
||||
saltB64 := encodingbase64.RawStdEncoding.EncodeToString(salt)
|
||||
hashB64 := encodingbase64.RawStdEncoding.EncodeToString(hash)
|
||||
phc := fmt.Sprintf(
|
||||
"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
params.Memory, params.Time, params.Threads,
|
||||
saltB64, hashB64,
|
||||
)
|
||||
return phc, nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks a plaintext password against a PHC-format Argon2id hash.
|
||||
// Returns true if the password matches.
|
||||
//
|
||||
// Security: Comparison uses crypto/subtle.ConstantTimeCompare after computing
|
||||
// the candidate hash with identical parameters and the stored salt. This
|
||||
// prevents timing attacks that could reveal whether a password is "closer" to
|
||||
// the correct value.
|
||||
func VerifyPassword(password, phcHash string) (bool, error) {
|
||||
params, salt, expectedHash, err := parsePHC(phcHash)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: parse PHC hash: %w", err)
|
||||
}
|
||||
|
||||
candidateHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
params.Time,
|
||||
params.Memory,
|
||||
params.Threads,
|
||||
uint32(len(expectedHash)),
|
||||
)
|
||||
|
||||
// Security: constant-time comparison prevents timing side-channels.
|
||||
if subtle.ConstantTimeCompare(candidateHash, expectedHash) != 1 {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// parsePHC parses a PHC-format Argon2id hash string.
|
||||
// Expected format: $argon2id$v=19$m=<M>,t=<T>,p=<P>$<salt-b64>$<hash-b64>
|
||||
func parsePHC(phc string) (ArgonParams, []byte, []byte, error) {
|
||||
parts := strings.Split(phc, "$")
|
||||
// Expected: ["", "argon2id", "v=19", "m=M,t=T,p=P", "salt", "hash"]
|
||||
if len(parts) != 6 {
|
||||
return ArgonParams{}, nil, nil, fmt.Errorf("auth: invalid PHC format: %d parts", len(parts))
|
||||
}
|
||||
if parts[1] != "argon2id" {
|
||||
return ArgonParams{}, nil, nil, fmt.Errorf("auth: unsupported algorithm %q", parts[1])
|
||||
}
|
||||
|
||||
var params ArgonParams
|
||||
for _, kv := range strings.Split(parts[3], ",") {
|
||||
eq := strings.IndexByte(kv, '=')
|
||||
if eq < 0 {
|
||||
return ArgonParams{}, nil, nil, fmt.Errorf("auth: invalid PHC param %q", kv)
|
||||
}
|
||||
k, v := kv[:eq], kv[eq+1:]
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return ArgonParams{}, nil, nil, fmt.Errorf("auth: parse PHC param %q: %w", kv, err)
|
||||
}
|
||||
switch k {
|
||||
case "m":
|
||||
params.Memory = uint32(n)
|
||||
case "t":
|
||||
params.Time = uint32(n)
|
||||
case "p":
|
||||
params.Threads = uint8(n)
|
||||
}
|
||||
}
|
||||
|
||||
salt, err := encodingbase64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return ArgonParams{}, nil, nil, fmt.Errorf("auth: decode salt: %w", err)
|
||||
}
|
||||
hash, err := encodingbase64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return ArgonParams{}, nil, nil, fmt.Errorf("auth: decode hash: %w", err)
|
||||
}
|
||||
return params, salt, hash, nil
|
||||
}
|
||||
|
||||
// ValidateTOTP checks a 6-digit TOTP code against a raw TOTP secret (bytes).
|
||||
// A ±1 time-step window (±30s) is allowed to accommodate clock skew.
|
||||
//
|
||||
// Security:
|
||||
// - Comparison uses crypto/subtle.ConstantTimeCompare to resist timing attacks.
|
||||
// - Only RFC 6238-compliant HOTP (HMAC-SHA1) is implemented; no custom crypto.
|
||||
// - A ±1 window is the RFC 6238 recommendation; wider windows increase
|
||||
// exposure to code interception between generation and submission.
|
||||
func ValidateTOTP(secret []byte, code string) (bool, error) {
|
||||
if len(code) != 6 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
step := int64(30) // RFC 6238 default time step in seconds
|
||||
|
||||
for _, counter := range []int64{
|
||||
now/step - 1,
|
||||
now / step,
|
||||
now/step + 1,
|
||||
} {
|
||||
expected, err := hotp(secret, uint64(counter))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("auth: compute TOTP: %w", err)
|
||||
}
|
||||
// Security: constant-time comparison to prevent timing attack.
|
||||
if subtle.ConstantTimeCompare([]byte(code), []byte(expected)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// hotp computes an HMAC-SHA1-based OTP for a given counter value.
|
||||
// Implements RFC 4226 §5, which is the base algorithm for RFC 6238 TOTP.
|
||||
//
|
||||
// Security: SHA-1 is used as required by RFC 4226/6238. It is used here in
|
||||
// an HMAC construction for OTP purposes — not for collision-resistant hashing.
|
||||
// The HMAC-SHA1 construction is still cryptographically sound for this use case.
|
||||
func hotp(key []byte, counter uint64) (string, error) {
|
||||
counterBytes := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(counterBytes, counter)
|
||||
|
||||
mac := hmac.New(sha1.New, key)
|
||||
if _, err := mac.Write(counterBytes); err != nil {
|
||||
return "", fmt.Errorf("auth: HMAC-SHA1 write: %w", err)
|
||||
}
|
||||
h := mac.Sum(nil)
|
||||
|
||||
// Dynamic truncation per RFC 4226 §5.3.
|
||||
offset := h[len(h)-1] & 0x0F
|
||||
binCode := (int(h[offset]&0x7F)<<24 |
|
||||
int(h[offset+1])<<16 |
|
||||
int(h[offset+2])<<8 |
|
||||
int(h[offset+3])) % int(math.Pow10(6))
|
||||
|
||||
return fmt.Sprintf("%06d", binCode), nil
|
||||
}
|
||||
|
||||
// DecodeTOTPSecret decodes a base32-encoded TOTP secret string to raw bytes.
|
||||
// TOTP authenticator apps present secrets in base32 for display; this function
|
||||
// converts them to the raw byte form stored (encrypted) in the database.
|
||||
func DecodeTOTPSecret(base32Secret string) ([]byte, error) {
|
||||
normalised := strings.ToUpper(strings.ReplaceAll(base32Secret, " ", ""))
|
||||
decoded, err := base32.StdEncoding.DecodeString(normalised)
|
||||
if err != nil {
|
||||
decoded, err = base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(normalised)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auth: decode base32 TOTP secret: %w", err)
|
||||
}
|
||||
}
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
// GenerateTOTPSecret generates a random 20-byte TOTP shared secret and returns
|
||||
// both the raw bytes and their base32 representation for display to the user.
|
||||
func GenerateTOTPSecret() (rawBytes []byte, base32Encoded string, err error) {
|
||||
rawBytes, err = crypto.RandomBytes(20)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("auth: generate TOTP secret: %w", err)
|
||||
}
|
||||
base32Encoded = base32.StdEncoding.EncodeToString(rawBytes)
|
||||
return rawBytes, base32Encoded, nil
|
||||
}
|
||||
216
internal/auth/auth_test.go
Normal file
216
internal/auth/auth_test.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestHashPasswordRoundTrip verifies that HashPassword + VerifyPassword works.
|
||||
func TestHashPasswordRoundTrip(t *testing.T) {
|
||||
params := DefaultArgonParams()
|
||||
hash, err := HashPassword("correct-horse-battery-staple", params)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(hash, "$argon2id$") {
|
||||
t.Errorf("hash does not start with $argon2id$: %q", hash)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword("correct-horse-battery-staple", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("VerifyPassword returned false for correct password")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPasswordWrongPassword verifies that a wrong password is rejected.
|
||||
func TestHashPasswordWrongPassword(t *testing.T) {
|
||||
params := DefaultArgonParams()
|
||||
hash, err := HashPassword("correct-horse", params)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
|
||||
ok, err := VerifyPassword("wrong-password", hash)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyPassword: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Error("VerifyPassword returned true for wrong password")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPasswordUniqueHashes verifies that the same password produces
|
||||
// different hashes (due to random salt).
|
||||
func TestHashPasswordUniqueHashes(t *testing.T) {
|
||||
params := DefaultArgonParams()
|
||||
h1, err := HashPassword("password", params)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword (1): %v", err)
|
||||
}
|
||||
h2, err := HashPassword("password", params)
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword (2): %v", err)
|
||||
}
|
||||
if h1 == h2 {
|
||||
t.Error("same password produced identical hashes (salt not random)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHashPasswordEmpty verifies that empty passwords are rejected.
|
||||
func TestHashPasswordEmpty(t *testing.T) {
|
||||
_, err := HashPassword("", DefaultArgonParams())
|
||||
if err == nil {
|
||||
t.Error("expected error for empty password, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyPasswordInvalidPHC verifies that malformed PHC strings are rejected.
|
||||
func TestVerifyPasswordInvalidPHC(t *testing.T) {
|
||||
_, err := VerifyPassword("password", "not-a-phc-string")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid PHC string, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyPasswordWrongAlgorithm verifies that non-argon2id PHC strings are
|
||||
// rejected.
|
||||
func TestVerifyPasswordWrongAlgorithm(t *testing.T) {
|
||||
fakeScrypt := "$scrypt$v=1$n=32768,r=8,p=1$c2FsdA$aGFzaA"
|
||||
_, err := VerifyPassword("password", fakeScrypt)
|
||||
if err == nil {
|
||||
t.Error("expected error for non-argon2id PHC string, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTOTP verifies that a correct TOTP code is accepted.
|
||||
// This test generates a secret and immediately validates the current code.
|
||||
func TestValidateTOTP(t *testing.T) {
|
||||
rawSecret, _, err := GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSecret: %v", err)
|
||||
}
|
||||
|
||||
// Compute the expected code for the current time step.
|
||||
now := time.Now().Unix()
|
||||
code, err := hotp(rawSecret, uint64(now/30))
|
||||
if err != nil {
|
||||
t.Fatalf("hotp: %v", err)
|
||||
}
|
||||
|
||||
ok, err := ValidateTOTP(rawSecret, code)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateTOTP: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Errorf("ValidateTOTP rejected a valid code %q", code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateTOTPWrongCode verifies that an incorrect code is rejected.
|
||||
func TestValidateTOTPWrongCode(t *testing.T) {
|
||||
rawSecret, _, err := GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSecret: %v", err)
|
||||
}
|
||||
|
||||
ok, err := ValidateTOTP(rawSecret, "000000")
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateTOTP: %v", err)
|
||||
}
|
||||
// 000000 is very unlikely to be correct; if it is, the test is flaky by
|
||||
// chance and should be re-run. The probability is ~3/1000000.
|
||||
_ = ok // we cannot assert false without knowing the actual code
|
||||
}
|
||||
|
||||
// TestValidateTOTPWrongLength verifies that codes of wrong length are rejected
|
||||
// without an error (they are simply invalid).
|
||||
func TestValidateTOTPWrongLength(t *testing.T) {
|
||||
rawSecret, _, err := GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSecret: %v", err)
|
||||
}
|
||||
|
||||
for _, code := range []string{"", "12345", "1234567", "abcdef"} {
|
||||
ok, err := ValidateTOTP(rawSecret, code)
|
||||
if err != nil {
|
||||
t.Errorf("ValidateTOTP(%q): unexpected error: %v", code, err)
|
||||
}
|
||||
if ok && len(code) != 6 {
|
||||
t.Errorf("ValidateTOTP accepted wrong-length code %q", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeTOTPSecret verifies base32 decoding with and without padding.
|
||||
func TestDecodeTOTPSecret(t *testing.T) {
|
||||
// A known base32-encoded 10-byte secret: JBSWY3DPEHPK3PXP (16 chars, padded)
|
||||
b32 := "JBSWY3DPEHPK3PXP"
|
||||
decoded, err := DecodeTOTPSecret(b32)
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeTOTPSecret: %v", err)
|
||||
}
|
||||
if len(decoded) == 0 {
|
||||
t.Error("DecodeTOTPSecret returned empty bytes")
|
||||
}
|
||||
|
||||
// Case-insensitive input.
|
||||
decoded2, err := DecodeTOTPSecret(strings.ToLower(b32))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeTOTPSecret lowercase: %v", err)
|
||||
}
|
||||
if string(decoded) != string(decoded2) {
|
||||
t.Error("case-insensitive decode produced different result")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecodeTOTPSecretInvalid verifies that invalid base32 is rejected.
|
||||
func TestDecodeTOTPSecretInvalid(t *testing.T) {
|
||||
_, err := DecodeTOTPSecret("not-valid-base32-!@#$%")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid base32, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTOTPSecret verifies that generated secrets are non-empty and
|
||||
// unique.
|
||||
func TestGenerateTOTPSecret(t *testing.T) {
|
||||
raw1, b32_1, err := GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSecret (1): %v", err)
|
||||
}
|
||||
if len(raw1) != 20 {
|
||||
t.Errorf("raw secret length = %d, want 20", len(raw1))
|
||||
}
|
||||
if b32_1 == "" {
|
||||
t.Error("base32 secret is empty")
|
||||
}
|
||||
|
||||
raw2, b32_2, err := GenerateTOTPSecret()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateTOTPSecret (2): %v", err)
|
||||
}
|
||||
if string(raw1) == string(raw2) {
|
||||
t.Error("two generated TOTP secrets are identical")
|
||||
}
|
||||
if b32_1 == b32_2 {
|
||||
t.Error("two generated TOTP base32 secrets are identical")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultArgonParams verifies that default params meet OWASP minimums.
|
||||
func TestDefaultArgonParams(t *testing.T) {
|
||||
p := DefaultArgonParams()
|
||||
if p.Time < 2 {
|
||||
t.Errorf("default Time=%d < OWASP minimum 2", p.Time)
|
||||
}
|
||||
if p.Memory < 65536 {
|
||||
t.Errorf("default Memory=%d KiB < OWASP minimum 64MiB (65536 KiB)", p.Memory)
|
||||
}
|
||||
if p.Threads < 1 {
|
||||
t.Errorf("default Threads=%d < 1", p.Threads)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user