mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
220 lines
5.5 KiB
Go
220 lines
5.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=. userdb.proto
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"unicode/utf8"
|
|
|
|
"golang.org/x/crypto/scrypt"
|
|
|
|
"blitiri.com.ar/go/chasquid/internal/protoio"
|
|
)
|
|
|
|
type DB struct {
|
|
fname string
|
|
db *ProtoDB
|
|
|
|
// Lock protecting db.
|
|
mu sync.RWMutex
|
|
}
|
|
|
|
var (
|
|
ErrInvalidUsername = errors.New("username contains invalid characters")
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
// Does this user exist in the database?
|
|
func (db *DB) Exists(user string) bool {
|
|
db.mu.RLock()
|
|
_, ok := db.db.Users[user]
|
|
db.mu.RUnlock()
|
|
|
|
return ok
|
|
}
|
|
|
|
// Is this password valid for the user?
|
|
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)
|
|
}
|
|
|
|
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)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Check if the given user name is valid.
|
|
// User names have to be UTF-8, and must not have some particular characters,
|
|
// including whitespace.
|
|
func ValidUsername(name string) bool {
|
|
return utf8.ValidString(name) &&
|
|
!strings.ContainsAny(name, illegalUsernameChars)
|
|
}
|
|
|
|
// Illegal characters. Only whitespace for now, to prevent/minimize the
|
|
// chances of parsing issues.
|
|
// TODO: do we want to stop other characters, specifically about email? Or
|
|
// keep this generic and handle the mail-specific filtering in chasquid?
|
|
const illegalUsernameChars = "\t\n\v\f\r \xa0\x85"
|
|
|
|
// Add a user to the database. If the user is already present, override it.
|
|
func (db *DB) AddUser(name, plainPassword string) error {
|
|
if !ValidUsername(name) {
|
|
return ErrInvalidUsername
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
///////////////////////////////////////////////////////////
|
|
// Encryption schemes
|
|
//
|
|
|
|
// 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)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
|
|
return bytes.Equal(dk, []byte(s.Encrypted))
|
|
}
|