This patch makes chasquid log how many users, aliases and DKIM keys were loaded for each domain. This makes it easier to confirm changes, and troubleshoot problems related to these per-domain configuration files.
247 lines
6.5 KiB
Go
247 lines
6.5 KiB
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"
|
|
"os"
|
|
"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 an error if the database could not be loaded. If
|
|
// the file does not exist, that is not considered an error.
|
|
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{}}
|
|
}
|
|
|
|
if os.IsNotExist(err) {
|
|
// If the file does not exist now, it is not an error, as it might
|
|
// exist later and we want to be able to read it.
|
|
err = nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// Len returns the number of users in the database.
|
|
func (db *DB) Len() int {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
return len(db.db.Users)
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////
|
|
// 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
|
|
}
|