From 23ff6225f5e25e4bffed9298a203f7201508052a Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 4 Oct 2025 02:17:51 -0600 Subject: [PATCH] feat(envauth): add verifiers for single-user credentials --- auth/envauth/README.md | 110 ++++++++++++++++++ auth/envauth/cmd/pbkdf2-sha256/main.go | 149 +++++++++++++++++++++++++ auth/envauth/cmd/salt/main.go | 52 +++++++++ auth/envauth/envauth.go | 93 +++++++++++++++ auth/envauth/envauth_test.go | 135 ++++++++++++++++++++++ auth/envauth/go.mod | 3 + 6 files changed, 542 insertions(+) create mode 100644 auth/envauth/README.md create mode 100644 auth/envauth/cmd/pbkdf2-sha256/main.go create mode 100644 auth/envauth/cmd/salt/main.go create mode 100644 auth/envauth/envauth.go create mode 100644 auth/envauth/envauth_test.go create mode 100644 auth/envauth/go.mod diff --git a/auth/envauth/README.md b/auth/envauth/README.md new file mode 100644 index 0000000..e48a0ad --- /dev/null +++ b/auth/envauth/README.md @@ -0,0 +1,110 @@ +# envauth + +Auth utils for single-user environments. \ +(standard library only, constant-time) + +- Password +- PBKDF2 Digest (sha-256) + +```go +creds := envauth.BasicCredentials{ + Username: os.Getenv("BASIC_AUTH_USERNAME"), + Password: os.Getenv("BASIC_AUTH_PASSWORD"), +} + +verified := creds.Verify("username", "password") +``` + +## Basic Credentials: Username + Password + +Plain-text username + password, typically something like `api:somereallylongapikey`. + +`.env`: + +```sh +export BASIC_AUTH_USERNAME="api" +export BASIC_AUTH_PASSWORD="secret" +``` + +```go +package main + +import ( + "os" + + "github.com/therootcompany/golib/auth/envauth" +) + +func main() { + username := os.Getenv("BASIC_AUTH_USERNAME") + password := os.Getenv("BASIC_AUTH_PASSWORD") + + creds := envauth.BasicCredentials{ + Username: username, + Password: password, + } + + verified := creds.Verify("api", "secret") + if verified { + println("Authentication successful") + } else { + println("Authentication failed") + } +} +``` + +## PBKDF2 Derived Key / Digest + +Salted and hashed password. + +```sh +go run ./cmd/pbkdf2-sha256/ 'secret' 'i63wDd7K-60' + +derived-key: 553ce8846c2304e93021dab03bacb5ca +``` + +`.env`: + +```sh +export BASIC_AUTH_USERNAME="api" +export BASIC_AUTH_PBKDF256_DERIVED_KEY="553ce8846c2304e93021dab03bacb5ca" +export BASIC_AUTH_PBKDF256_SALT="i63wDd7K-60" +export BASIC_AUTH_PBKDF256_ITERATIONS=1000 +``` + +```go +package main + +import ( + "encoding/base64" + "encoding/hex" + "os" + + "github.com/therootcompany/golib/auth/envauth" +) + +func main() { + username := os.Getenv("BASIC_AUTH_USERNAME") + derivedKeyHex := os.Getenv("BASIC_AUTH_PBKDF256_DERIVED_KEY") + saltBase64 := os.Getenv("BASIC_AUTH_PBKDF256_SALT") + itersStr := os.Getenv("BASIC_AUTH_PBKDF256_ITERATIONS") + + derivedKey, _ := hex.DecodeString(derivedKeyB64) + salt, _ := base64.URLEncoding.DecodeString(saltHex) + iterations, _ := strconv.Atoi(itersStr) + + creds := envauth.PBKDF2Credentials{ + Username: username, + DerivedKey: derivedKey, + Salt: salt, + Iterations: iterations, + } + + verified := creds.Verify("api", "secret") + if verified { + println("Authentication successful") + } else { + println("Authentication failed") + } +} +``` diff --git a/auth/envauth/cmd/pbkdf2-sha256/main.go b/auth/envauth/cmd/pbkdf2-sha256/main.go new file mode 100644 index 0000000..fc4e46c --- /dev/null +++ b/auth/envauth/cmd/pbkdf2-sha256/main.go @@ -0,0 +1,149 @@ +package main + +import ( + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "strconv" + "strings" +) + +var version = "v1.0.0" +var help = "pbkdf2-sha256 [password=random] [salt=random] [iterations=1000] [keySize=16]" + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "-V", "version", "-version", "--version": + fmt.Println(version) + return + case "help", "-help", "--help": + fmt.Println("Usage:", help) + os.Exit(0) + return + } + } + + var password string + var salt []byte + var iterations int + var keySize int + var err error + + // Default values + iterations = 1000 + keySize = 16 + + // Parse arguments + args := os.Args[1:] + if len(args) > 4 { + fmt.Fprintf(os.Stderr, "USAGE\n\t%s\n", help) + return + } + + // Password + if len(args) > 0 && args[0] != "" { + password = args[0] + } else { + fmt.Fprintf(os.Stderr, "\nUSAGE\n\t%s\n\n", help) + rnd := make([]byte, 8) + _, _ = rand.Read(rnd) + hexPass := hex.EncodeToString(rnd) + password = fmt.Sprintf("%s-%s-%s-%s", hexPass[:4], hexPass[4:8], hexPass[8:12], hexPass[12:]) + fmt.Printf("password : %s\n", password) + } + + // Salt + if len(args) > 1 && args[1] != "" { + saltStr := args[1] + salt, err = parseHexOrBase64(saltStr) + if err != nil { + fmt.Fprintf(os.Stderr, "Error decoding salt: %v\n", err) + return + } + } else { + salt = make([]byte, 16) + _, _ = rand.Read(salt) + fmt.Printf("salt : %s\n", base64.RawURLEncoding.EncodeToString(salt)) + } + + // Iterations + if len(args) > 2 && args[2] != "0" && args[2] != "" { + iterations, err = parseInt(args[2]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing iterations: %v\n", err) + return + } + } else { + fmt.Printf("iterations : %d\n", iterations) + } + + // Key size + if len(args) > 3 && args[3] != "0" && args[3] != "" { + keySize, err = parseInt(args[3]) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing key size: %v\n", err) + return + } + } else { + fmt.Printf("key-size : %d\n", keySize) + } + + // Generate PBKDF2 key + derivedKey, err := pbkdf2.Key(sha256.New, password, salt, iterations, keySize) + if err != nil { + fmt.Fprintf(os.Stderr, "Error parsing key size: %v\n", err) + return + } + fmt.Printf("derived-key: %s\n\n", hex.EncodeToString(derivedKey)) +} + +func parseHexOrBase64(data string) ([]byte, error) { + var b []byte + + // Check if salt is hex (all uppercase or lowercase, valid hex chars) + isHex := true + for _, c := range data { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + isHex = false + break + } + } + // Check for mixed case + hasUpper := strings.ContainsAny(data, "ABCDEF") + hasLower := strings.ContainsAny(data, "abcdef") + if isHex && !(hasUpper && hasLower) { + var err error + b, err = hex.DecodeString(data) + if err != nil { + return nil, err + } + } else { + // Assume URL-safe base64, convert to RFC base64 + rfcData := strings.ReplaceAll(data, "-", "+") + rfcData = strings.ReplaceAll(rfcData, "_", "/") + rfcData = strings.ReplaceAll(rfcData, "=", "") + var err error + b, err = base64.RawStdEncoding.DecodeString(rfcData) + if err != nil { + return nil, err + } + } + + return b, nil +} + +func parseInt(s string) (int, error) { + n, err := strconv.Atoi(s) + if err != nil { + return 0, err + } + if n < 0 { + return 0, fmt.Errorf("value must be positive") + } + return n, nil +} diff --git a/auth/envauth/cmd/salt/main.go b/auth/envauth/cmd/salt/main.go new file mode 100644 index 0000000..8dcbfd7 --- /dev/null +++ b/auth/envauth/cmd/salt/main.go @@ -0,0 +1,52 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "os" + "strconv" + "strings" +) + +var version = "v1.0.0" +var help = "salt [size=16]" + +func main() { + if len(os.Args) > 1 { + switch os.Args[1] { + case "-V", "version", "-version", "--version": + fmt.Println(version) + return + case "help", "-help", "--help": + fmt.Println("Usage:", help) + os.Exit(0) + return + } + } + + var err error + var size int + + switch len(os.Args) { + case 1: + size = 16 + case 2: + size, err = strconv.Atoi(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "Usage: %s\n", help) + return + } + default: + fmt.Fprintf(os.Stderr, "Usage: %s\n", help) + return + } + + salt := make([]byte, size) + _, _ = rand.Read(salt) + fmt.Printf("hex : %s\n", hex.EncodeToString(salt)) + fmt.Printf("HEX : %s\n", strings.ToUpper(hex.EncodeToString(salt))) + fmt.Printf("url-base64: %s\n", base64.RawURLEncoding.EncodeToString(salt)) + fmt.Printf("rfc-base64: %s\n", base64.StdEncoding.EncodeToString(salt)) +} diff --git a/auth/envauth/envauth.go b/auth/envauth/envauth.go new file mode 100644 index 0000000..9cd1f1c --- /dev/null +++ b/auth/envauth/envauth.go @@ -0,0 +1,93 @@ +package envauth + +import ( + "crypto/pbkdf2" + "crypto/sha256" + "crypto/subtle" +) + +type BasicAuthVerifier interface { + Verify(string, string) bool +} + +// BasicCredentials holds user credentials +type BasicCredentials struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Returns true if username and password match. +// Uses SHA-256 and constant-time techniques to avoid revealing whether the username or password matches through timing attacks. +func (c BasicCredentials) Verify(username string, password string) bool { + if len(password) == 0 { + return false + } + + equal := 1 + + // We hash rather than completely relying on subtle.ConstantTimeCompare([]byte(c.Username), []byte(username)) + // out of an abundance of caution since optimizations have caused similar methods to fail in other languages. + // (we also use it because it gives back 1 rather than true, which we can use in the next step) + knownUsernameHash := sha256.Sum256([]byte(c.Username)) + usernameHash := sha256.Sum256([]byte(username)) + v := subtle.ConstantTimeCompare(knownUsernameHash[:], usernameHash[:]) // 1 if same + equal = subtle.ConstantTimeSelect(v, equal, 0) // v ? x : y + + knownPasswordHash := sha256.Sum256([]byte(c.Password)) + passwordHash := sha256.Sum256([]byte(password)) + v = subtle.ConstantTimeCompare(knownPasswordHash[:], passwordHash[:]) // 1 if same + equal = subtle.ConstantTimeSelect(v, equal, 0) // v ? x : y + + return equal == 1 +} + +// PBKDF2Credentials holds user credentials +type PBKDF2Credentials struct { + Username string `json:"username"` + DerivedKey []byte `json:"derived_key"` + Salt []byte `json:"salt"` // should be at least 8 bytes + Iterations int `json:"iterations"` +} + +// Returns true if username and password match. +// Uses PBKDF2 and constant-time techniques to avoid revealing whether the username or password matches through timing attacks. +func (c PBKDF2Credentials) Verify(username string, password string) bool { + keyLen := len(c.DerivedKey) + dkKnownUser, err := pbkdf2.Key(sha256.New, c.Username, c.Salt, c.Iterations, keyLen) + if err != nil { + return false + } + + if len(password) == 0 { + return false + } + + dkUser, err := pbkdf2.Key(sha256.New, username, c.Salt, c.Iterations, keyLen) + if err != nil { + return false + } + dkPass, err := pbkdf2.Key(sha256.New, password, c.Salt, c.Iterations, keyLen) + if err != nil { + return false + } + + equal := 1 + + v := subtle.ConstantTimeCompare(dkUser, dkKnownUser) // 1 if same + equal = subtle.ConstantTimeSelect(v, equal, 0) // v ? x : y + + v = subtle.ConstantTimeCompare(dkPass, c.DerivedKey) // 1 if same + equal = subtle.ConstantTimeSelect(v, equal, 0) // v ? x : y + + return equal == 1 +} + +func (c PBKDF2Credentials) DeriveKey(username string, password string, keyLen int) ([]byte, error) { + if keyLen == 0 { + keyLen = len(c.DerivedKey) + } + return pbkdf2.Key(sha256.New, password, c.Salt, c.Iterations, keyLen) +} + +var _ BasicAuthVerifier = (*BasicCredentials)(nil) +var _ BasicAuthVerifier = (*PBKDF2Credentials)(nil) diff --git a/auth/envauth/envauth_test.go b/auth/envauth/envauth_test.go new file mode 100644 index 0000000..138d611 --- /dev/null +++ b/auth/envauth/envauth_test.go @@ -0,0 +1,135 @@ +package envauth + +import ( + "crypto/pbkdf2" + "crypto/sha256" + "testing" +) + +var salt = []byte("buzzword") + +func TestBasicCredentials_Verify(t *testing.T) { + tests := []struct { + name string + creds BasicCredentials + username string + password string + want bool + }{ + { + name: "empty username, correct password", + creds: BasicCredentials{Username: "", Password: "secret"}, + username: "", + password: "secret", + want: true, + }, + { + name: "correct username, correct password", + creds: BasicCredentials{Username: "user", Password: "secret"}, + username: "user", + password: "secret", + want: true, + }, + { + name: "incorrect username, correct password", + creds: BasicCredentials{Username: "user", Password: "secret"}, + username: "wrong", + password: "secret", + want: false, + }, + { + name: "correct username, incorrect password", + creds: BasicCredentials{Username: "user", Password: "secret"}, + username: "user", + password: "wrong", + want: false, + }, + { + name: "correct username, empty password", + creds: BasicCredentials{Username: "user", Password: "secret"}, + username: "user", + password: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.creds.Verify(tt.username, tt.password) + if got != tt.want { + t.Errorf("Verify(%q, %q) = %v; want %v", tt.username, tt.password, got, tt.want) + } + }) + } +} + +func TestPBKDF2Credentials_Verify(t *testing.T) { + secretDigest, err := pbkdf2.Key(sha256.New, "secret", salt, 1000, 16) + if err != nil { + t.Errorf("pbkdf2.Key(sha256.New, \"secret\", salt, 1000, 16) = %v", err) + } + emptyDigest, err := pbkdf2.Key(sha256.New, "", salt, 1000, 16) + if err != nil { + t.Errorf("pbkdf2.Key(sha256.New, \"\", salt, 1000, 16) = %v", err) + } + + tests := []struct { + name string + creds PBKDF2Credentials + username string + password string + want bool + }{ + { + name: "empty username, correct password", + creds: PBKDF2Credentials{Username: "", DerivedKey: secretDigest, Salt: salt, Iterations: 1000}, + username: "", + password: "secret", + want: true, + }, + { + name: "correct username, correct password", + creds: PBKDF2Credentials{Username: "user", DerivedKey: secretDigest, Salt: salt, Iterations: 1000}, + username: "user", + password: "secret", + want: true, + }, + { + name: "incorrect username, correct password", + creds: PBKDF2Credentials{Username: "user", DerivedKey: secretDigest, Salt: salt, Iterations: 1000}, + username: "wrong", + password: "secret", + want: false, + }, + { + name: "correct username, incorrect password", + creds: PBKDF2Credentials{Username: "user", DerivedKey: secretDigest, Salt: salt, Iterations: 1000}, + username: "user", + password: "wrong", + want: false, + }, + { + name: "correct username, empty password", + creds: PBKDF2Credentials{Username: "user", DerivedKey: secretDigest, Salt: salt, Iterations: 1000}, + username: "user", + password: "", + want: false, + }, + { + name: "empty username, empty pre-computed digest", + creds: PBKDF2Credentials{Username: "", DerivedKey: emptyDigest, Salt: salt, Iterations: 1000}, + username: "", + password: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.creds.Verify(tt.username, tt.password) + if got != tt.want { + t.Errorf("Verify(%q, %q) = %v; want %v", tt.username, tt.password, got, tt.want) + } + }) + } +} diff --git a/auth/envauth/go.mod b/auth/envauth/go.mod new file mode 100644 index 0000000..3a3d7d9 --- /dev/null +++ b/auth/envauth/go.mod @@ -0,0 +1,3 @@ +module github.com/therootcompany/golib/auth/envauth + +go 1.24.6