git » chasquid » master » tree

[master] / internal / userdb / userdb.go

// Package userdb implements a simple user database.
//
// # Format
//
// The user database is a file containing a list of users and their passwords,
// encrypted with some scheme.
// We use a text-encoded protobuf, the structure can be found in userdb.proto.
//
// We write text instead of binary to make it easier for administrators to
// troubleshoot, and since performance is not an issue for our expected usage.
//
// Users must be UTF-8 and NOT contain whitespace; the library will enforce
// this.
//
// # Schemes
//
// The default scheme is SCRYPT, with hard-coded parameters. The API does not
// allow the user to change this, at least for now.
// A PLAIN scheme is also supported for debugging purposes.
//
// # Writing
//
// The functions that write a database file will not preserve ordering,
// invalid lines, empty lines, or any formatting.
//
// It is also not safe for concurrent use from different processes.
package userdb

//go:generate protoc --go_out=. --go_opt=paths=source_relative userdb.proto

import (
	"crypto/rand"
	"crypto/subtle"
	"errors"
	"fmt"
	"sync"

	"golang.org/x/crypto/scrypt"

	"blitiri.com.ar/go/chasquid/internal/normalize"
	"blitiri.com.ar/go/chasquid/internal/protoio"
)

// DB represents a single user database.
type DB struct {
	fname string
	db    *ProtoDB

	// Lock protecting db.
	mu sync.RWMutex
}

// New returns a new user database, on the given file name.
func New(fname string) *DB {
	return &DB{
		fname: fname,
		db:    &ProtoDB{Users: map[string]*Password{}},
	}
}

// Load the database from the given file.
// Return the database, and a fatal error if the database could not be
// loaded.
func Load(fname string) (*DB, error) {
	db := New(fname)
	err := protoio.ReadTextMessage(fname, db.db)

	// Reading may result in an empty protobuf or dictionary; make sure we
	// return an empty but usable structure.
	// This simplifies many of our uses, as we can assume the map is not nil.
	if db.db == nil || db.db.Users == nil {
		db.db = &ProtoDB{Users: map[string]*Password{}}
	}

	return db, err
}

// Reload the database, refreshing its contents from the current file on disk.
// If there are errors reading from the file, they are returned and the
// database is not changed.
func (db *DB) Reload() error {
	newdb, err := Load(db.fname)
	if err != nil {
		return err
	}

	db.mu.Lock()
	db.db = newdb.db
	db.mu.Unlock()

	return nil
}

// Write the database to disk. It will do a complete rewrite each time, and is
// not safe to call it from different processes in parallel.
func (db *DB) Write() error {
	db.mu.RLock()
	defer db.mu.RUnlock()

	return protoio.WriteTextMessage(db.fname, db.db, 0660)
}

// Authenticate returns true if the password is valid for the user, false
// otherwise.
func (db *DB) Authenticate(name, plainPassword string) bool {
	db.mu.RLock()
	passwd, ok := db.db.Users[name]
	db.mu.RUnlock()

	if !ok {
		return false
	}

	return passwd.PasswordMatches(plainPassword)
}

// PasswordMatches returns true if the given password is a match.
func (p *Password) PasswordMatches(plain string) bool {
	switch s := p.Scheme.(type) {
	case nil:
		return false
	case *Password_Scrypt:
		return s.Scrypt.PasswordMatches(plain)
	case *Password_Plain:
		return s.Plain.PasswordMatches(plain)
	case *Password_Denied:
		return false
	default:
		return false
	}
}

// AddUser to the database. If the user is already present, override it.
// Note we enforce that the name has been normalized previously.
func (db *DB) AddUser(name, plainPassword string) error {
	if norm, err := normalize.User(name); err != nil || name != norm {
		return errors.New("invalid username")
	}

	s := &Scrypt{
		// Use hard-coded standard parameters for now.
		// Follow the recommendations from the scrypt paper.
		LogN: 14, R: 8, P: 1, KeyLen: 32,

		// 16 bytes of salt (will be filled later).
		Salt: make([]byte, 16),
	}

	n, err := rand.Read(s.Salt)
	if n != 16 || err != nil {
		return fmt.Errorf("failed to get salt - %d - %v", n, err)
	}

	s.Encrypted, err = scrypt.Key([]byte(plainPassword), s.Salt,
		1<<s.LogN, int(s.R), int(s.P), int(s.KeyLen))
	if err != nil {
		return fmt.Errorf("scrypt failed: %v", err)
	}

	db.mu.Lock()
	db.db.Users[name] = &Password{
		Scheme: &Password_Scrypt{s},
	}
	db.mu.Unlock()

	return nil
}

// AddDenied to the database. If the user is already present, override it.
// Note we enforce that the name has been normalized previously.
func (db *DB) AddDeniedUser(name string) error {
	if norm, err := normalize.User(name); err != nil || name != norm {
		return errors.New("invalid username")
	}

	db.mu.Lock()
	db.db.Users[name] = &Password{
		Scheme: &Password_Denied{&Denied{}},
	}
	db.mu.Unlock()

	return nil
}

// RemoveUser from the database. Returns True if the user was there, False
// otherwise.
func (db *DB) RemoveUser(name string) bool {
	db.mu.Lock()
	_, present := db.db.Users[name]
	delete(db.db.Users, name)
	db.mu.Unlock()
	return present
}

// Exists returns true if the user is present, false otherwise.
func (db *DB) Exists(name string) bool {
	db.mu.Lock()
	_, present := db.db.Users[name]
	db.mu.Unlock()
	return present
}

///////////////////////////////////////////////////////////
// Encryption schemes
//

// PasswordMatches implementation for the plain text scheme.
// Useful mostly for testing and debugging.
// TODO: Do we really need this? Removing it would make accidents less likely
// to happen. Consider doing so when we add another scheme, so we a least have
// two and multi-scheme support does not bit-rot.
func (p *Plain) PasswordMatches(plain string) bool {
	return plain == string(p.Password)
}

// PasswordMatches implementation for the scrypt scheme, which we use by
// default.
func (s *Scrypt) PasswordMatches(plain string) bool {
	dk, err := scrypt.Key([]byte(plain), s.Salt,
		1<<s.LogN, int(s.R), int(s.P), int(s.KeyLen))

	if err != nil {
		// The encryption failed, this is due to the parameters being invalid.
		// We validated them before, so something went really wrong.
		// TODO: do we want to return false instead?
		panic(fmt.Sprintf("scrypt failed: %v", err))
	}

	// This comparison should be high enough up the stack that it doesn't
	// matter, but do it in constant time just in case.
	return subtle.ConstantTimeCompare(dk, []byte(s.Encrypted)) == 1
}