internal/oidcprovider/users.go
Ref: Size: 5.7 KiB History
// Package oidcprovider is a minimal, spec-compliant OIDC issuer: discovery,
// authorization-code + PKCE, token, and JWKS, with users in a flat file. It is
// the library behind the eitri-oidc binary and is driven through go-oidc in
// tests so it can't drift from the standard client eitri-server uses. A
// standalone leaf: it imports nothing from the server or agent.
package oidcprovider
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/crypto/argon2"
)
// argon2id parameters: 64 MiB, 1 pass, 4 lanes, 16-byte salt, 32-byte key.
const (
argonMemory = 64 * 1024
argonTime = 1
argonThreads = 4
argonSaltLen = 16
argonKeyLen = 32
)
// User is one flat-file identity. Hash is omitted from the auth-facing form.
type User struct {
Email string `json:"email"`
Sub string `json:"sub"`
Hash string `json:"hash"`
}
// Users is the on-disk file shape: {"users": [...]}.
type Users struct {
Users []User `json:"users"`
}
// LoadUsers reads the users file. A missing file is an empty set, not an error,
// so `user add` works before the daemon has ever run.
func LoadUsers(path string) (Users, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return Users{}, nil
}
if err != nil {
return Users{}, err
}
var us Users
if err := json.Unmarshal(b, &us); err != nil {
return Users{}, err
}
return us, nil
}
// AddUser appends or replaces email's entry with a fresh argon2id hash of
// password, atomically. Re-adding an existing email replaces its hash but keeps
// its sub — a password change must not rebind the tenant. A new user gets a
// random 16-byte-hex sub.
func AddUser(path, email, password string) error {
us, err := LoadUsers(path)
if err != nil {
return err
}
hash, err := hashPassword(password)
if err != nil {
return err
}
for i := range us.Users {
if us.Users[i].Email == email {
us.Users[i].Hash = hash
return writeUsers(path, us)
}
}
us.Users = append(us.Users, User{Email: email, Sub: random16(), Hash: hash})
return writeUsers(path, us)
}
// RemoveUser drops email's entry. Absent email is a no-op.
func RemoveUser(path, email string) error {
us, err := LoadUsers(path)
if err != nil {
return err
}
out := us.Users[:0]
for _, u := range us.Users {
if u.Email != email {
out = append(out, u)
}
}
us.Users = out
return writeUsers(path, us)
}
// ListUsers returns the file's entries (empty if the file is missing).
func ListUsers(path string) []User {
us, _ := LoadUsers(path)
return us.Users
}
// Authenticate re-reads the file per call — the daemon holds no cached copy, so
// `user add`/`rm` take effect whether or not it is running — and reports whether
// password matches email. The returned User carries no hash.
func Authenticate(path, email, password string) (User, bool) {
us, err := LoadUsers(path)
if err != nil {
return User{}, false
}
for _, u := range us.Users {
if u.Email == email && verifyPassword(password, u.Hash) {
return User{Email: u.Email, Sub: u.Sub}, true
}
}
// Unknown email burns the same argon2 work as a wrong password, so the
// miss path is not a user-enumeration timing oracle.
verifyPassword(password, dummyHash)
return User{}, false
}
// dummyHash is a throwaway argon2id hash (of an unguessable random string)
// used to equalize Authenticate's timing on the unknown-email path.
var dummyHash = func() string {
h, err := hashPassword(random16())
if err != nil {
// hashPassword only fails if crypto/rand does; unreachable in practice.
panic(err)
}
return h
}()
func random16() string {
b := make([]byte, 16)
rand.Read(b) //nolint:errcheck // crypto/rand.Read never returns an error
return hex.EncodeToString(b)
}
// hashPassword encodes as $argon2id$v=19$m=,t=,p=$<b64 salt>$<b64 key>.
func hashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(key)), nil
}
// verifyPassword recomputes the hash with the encoded parameters and compares
// in constant time.
func verifyPassword(password, encoded string) bool {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", "<salt>", "<key>"]
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var version int
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil {
return false
}
var memory, time, threads uint32
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(password), salt, time, memory, uint8(threads), uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// writeUsers serializes atomically: write a temp file in the same dir, then
// rename over the target.
func writeUsers(path string, us Users) error {
b, err := json.MarshalIndent(us, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".users-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmpName, path)
}