feat(auth): add safe Argon2id local user registry
This commit is contained in:
@@ -0,0 +1,169 @@
|
||||
package authconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"github.com/gofrs/flock"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
authFileName = "auth.yaml"
|
||||
usersFileName = "users.yaml"
|
||||
lockFileName = ".auth.lock"
|
||||
)
|
||||
|
||||
// Load reads auth.yaml and, for local authentication, the paired users.yaml registry. All reads
|
||||
// are bounded and reject non-private, linked, or symlinked files.
|
||||
func Load(directory string) (Config, Registry, error) {
|
||||
return load(directory)
|
||||
}
|
||||
|
||||
func load(directory string) (Config, Registry, error) {
|
||||
if err := requirePrivateDirectory(directory); err != nil {
|
||||
return Config{}, Registry{}, err
|
||||
}
|
||||
authContents, err := readPrivateFile(filepath.Join(directory, authFileName))
|
||||
if err != nil {
|
||||
return Config{}, Registry{}, err
|
||||
}
|
||||
var config Config
|
||||
if err := decodeStrictYAML(authContents, &config); err != nil || !validConfig(config) {
|
||||
return Config{}, Registry{}, errInvalidAuthenticationConfig
|
||||
}
|
||||
if config.Mode != "local" {
|
||||
return config, Registry{}, nil
|
||||
}
|
||||
usersContents, err := readPrivateFile(filepath.Join(directory, config.Local.UsersFile))
|
||||
if err != nil {
|
||||
return Config{}, Registry{}, err
|
||||
}
|
||||
var registry Registry
|
||||
if err := decodeStrictYAML(usersContents, ®istry); err != nil {
|
||||
return Config{}, Registry{}, errInvalidAuthenticationConfig
|
||||
}
|
||||
if err := validateRegistry(registry); err != nil {
|
||||
return Config{}, Registry{}, errInvalidAuthenticationConfig
|
||||
}
|
||||
return config, registry, nil
|
||||
}
|
||||
|
||||
// MutateUsers serializes the entire read-check-write transaction under the configuration lock.
|
||||
// The resulting registry is revalidated and atomically replaced only after all invariants hold.
|
||||
func MutateUsers(directory string, mutate func(*Registry) error) error {
|
||||
if mutate == nil {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
if err := requirePrivateDirectory(directory); err != nil {
|
||||
return err
|
||||
}
|
||||
lock, err := acquireLock(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = lock.Unlock() }()
|
||||
|
||||
config, registry, err := load(directory)
|
||||
if err != nil || config.Mode != "local" {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
before := cloneRegistry(registry)
|
||||
if err := mutate(®istry); err != nil {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
if err := applyMutationInvariants(before, ®istry); err != nil {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
contents, err := yaml.Marshal(registry)
|
||||
if err != nil {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
contents = append(contents, '\n')
|
||||
if err := safeio.ReplaceCanonicalRegular(filepath.Join(directory, config.Local.UsersFile), contents, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validConfig(config Config) bool {
|
||||
if config.Version != 1 || (config.Mode != "local" && config.Mode != "oidc") {
|
||||
return false
|
||||
}
|
||||
return config.Mode != "local" || config.Local.UsersFile == usersFileName
|
||||
}
|
||||
|
||||
func decodeStrictYAML(contents []byte, destination any) error {
|
||||
decoder := yaml.NewDecoder(bytes.NewReader(contents))
|
||||
decoder.KnownFields(true)
|
||||
if err := decoder.Decode(destination); err != nil {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
|
||||
return errInvalidAuthenticationConfig
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requirePrivateDirectory(directory string) error {
|
||||
if err := safeio.ValidateCanonicalPath(directory); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return safeio.ErrUnsafeFile
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(directory)
|
||||
if err != nil || resolved != directory {
|
||||
return safeio.ErrUnsafeFile
|
||||
}
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o700 {
|
||||
return safeio.ErrUnsafeFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readPrivateFile(path string) ([]byte, error) {
|
||||
before, err := os.Lstat(path)
|
||||
if err != nil || !before.Mode().IsRegular() || before.Mode()&os.ModeSymlink != 0 || (runtime.GOOS != "windows" && before.Mode().Perm() != 0o600) {
|
||||
return nil, safeio.ErrUnsafeFile
|
||||
}
|
||||
contents, err := safeio.ReadCanonicalRegular(path, maxYAMLBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := os.Lstat(path)
|
||||
if err != nil || !after.Mode().IsRegular() || after.Mode()&os.ModeSymlink != 0 || (runtime.GOOS != "windows" && after.Mode().Perm() != 0o600) || !os.SameFile(before, after) {
|
||||
return nil, safeio.ErrUnsafeFile
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func acquireLock(directory string) (*flock.Flock, error) {
|
||||
path := filepath.Join(directory, lockFileName)
|
||||
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||
if err := safeio.WriteCanonicalNewFile(path, nil, 0o600); err != nil {
|
||||
info, statErr := os.Lstat(path)
|
||||
if statErr != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || (runtime.GOOS != "windows" && info.Mode().Perm() != 0o600) {
|
||||
return nil, safeio.ErrUnsafeFile
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
return nil, safeio.ErrUnsafeFile
|
||||
}
|
||||
lock := flock.New(path, flock.SetPermissions(0o600))
|
||||
if err := lock.Lock(); err != nil {
|
||||
return nil, errInvalidAuthenticationConfig
|
||||
}
|
||||
if _, err := readPrivateFile(path); err != nil {
|
||||
_ = lock.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
return lock, nil
|
||||
}
|
||||
Reference in New Issue
Block a user