// 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<