1
0
mirror of https://blitiri.com.ar/repos/chasquid synced 2025-12-18 14:47:03 +00:00
Files
go-chasquid-smtp/internal/userdb/userdb.go
Alberto Bertogli 0995eac474 chasquid: Fail at RCPT TO time if a user does not exist
It's more convenient and in line with standard practice to fail RCPT TO if the
user does not exist.

This involves making the server and client aware of aliases, but it doesn't
end up being very convoluted, and simplifies other code.
2016-10-10 00:51:04 +01:00

239 lines
5.9 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
}
// 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
}
// HasUser returns true if the user is present, False otherwise.
func (db *DB) HasUser(name string) bool {
db.mu.Lock()
_, present := db.db.Users[name]
db.mu.Unlock()
return present
}
///////////////////////////////////////////////////////////
// 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))
}