a73x

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)
}