git » chasquid » commit 9015e97

Implement a simple user database (internal/userdb)

author Alberto Bertogli
2016-07-12 01:03:36 UTC
committer Alberto Bertogli
2016-07-16 11:33:50 UTC
parent c12fa0eed7a325396237b8c8e27574e09185b447

Implement a simple user database (internal/userdb)

This patch adds a package implementing a simple user database, called userdb.
It has a human readable space-separated extensible format, and uses scrypt for
password storage (but supports plain as well, for debugging and testing).

chasquid is not using it yet, that will come in later patches.

internal/userdb/userdb.go +353 -0
internal/userdb/userdb_test.go +282 -0

diff --git a/internal/userdb/userdb.go b/internal/userdb/userdb.go
new file mode 100644
index 0000000..46ca213
--- /dev/null
+++ b/internal/userdb/userdb.go
@@ -0,0 +1,353 @@
+// Package userdb implements a simple user database.
+//
+//
+// Format
+//
+// The user database is a single text file, with one line per user.
+// All contents must be UTF-8.
+//
+// For extensibility, the first line MUST be:
+//
+//   #chasquid-userdb-v1
+//
+// Then, each line is structured as follows:
+//
+//   user SP scheme SP password
+//
+// Where user is the username in question (usually without the domain,
+// although this package is agnostic to it); scheme is the encryption scheme
+// used for the password; and finally the password, encrypted with the
+// referenced scheme and base64-encoded.
+//
+// Lines with parsing errors, including unknown schemes, will be ignored.
+// Users must be UTF-8 and NOT contain whitespace; the library will enforce
+// this as well.
+//
+//
+// 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
+
+import (
+	"bufio"
+	"bytes"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+	"os"
+	"strings"
+	"sync"
+	"unicode/utf8"
+
+	"golang.org/x/crypto/scrypt"
+
+	"blitiri.com.ar/go/chasquid/internal/safeio"
+)
+
+type user struct {
+	name     string
+	scheme   scheme
+	password string
+}
+
+type DB struct {
+	fname string
+	finfo os.FileInfo
+
+	// Map of username -> user structure
+	users map[string]user
+
+	// Lock protecting the users map.
+	mu sync.RWMutex
+}
+
+var (
+	MissingHeaderErr   = errors.New("missing '#chasquid-userdb-v1' header")
+	InvalidUsernameErr = errors.New("username contains invalid characters")
+)
+
+// Load the database from the given file.
+// Return the database, a list of warnings (if any), and a fatal error if the
+// database could not be loaded.
+func Load(fname string) (*DB, []error, error) {
+	f, err := os.Open(fname)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	db := &DB{
+		fname: fname,
+		users: map[string]user{},
+	}
+
+	db.finfo, err = f.Stat()
+	if err != nil {
+		return nil, nil, err
+	}
+
+	// Special case: an empty file is a valid, empty database.
+	// This simplifies clients.
+	if db.finfo.Size() == 0 {
+		return db, nil, nil
+	}
+
+	scanner := bufio.NewScanner(f)
+	scanner.Scan()
+	if scanner.Text() != "#chasquid-userdb-v1" {
+		return nil, nil, MissingHeaderErr
+	}
+
+	var warnings []error
+
+	// Now the users, one per line. Skip invalid ones.
+	for i := 2; scanner.Scan(); i++ {
+		var name, schemeStr, b64passwd string
+		n, err := fmt.Sscanf(scanner.Text(), "%s %s %s",
+			&name, &schemeStr, &b64passwd)
+		if err != nil || n != 3 {
+			warnings = append(warnings, fmt.Errorf(
+				"line %d: error parsing - %d elements - %v", i, n, err))
+			break
+		}
+
+		if !ValidUsername(name) {
+			warnings = append(warnings, fmt.Errorf(
+				"line %d: invalid username", i))
+			continue
+		}
+
+		password, err := base64.StdEncoding.DecodeString(b64passwd)
+		if err != nil {
+			warnings = append(warnings, fmt.Errorf(
+				"line %d: error decoding password: %v", i, err))
+			continue
+		}
+
+		sc, err := schemeFromString(schemeStr)
+		if err != nil {
+			warnings = append(warnings, fmt.Errorf(
+				"line %d: error in scheme: %v", i, err))
+			continue
+		}
+
+		u := user{
+			name:     name,
+			scheme:   sc,
+			password: string(password),
+		}
+		db.users[name] = u
+	}
+
+	if err := scanner.Err(); err != nil {
+		return nil, warnings, err
+	}
+
+	return db, warnings, nil
+}
+
+// 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. Warnings are returned regardless.
+func (db *DB) Reload() ([]error, error) {
+	newdb, warnings, err := Load(db.fname)
+	if err != nil {
+		return warnings, err
+	}
+
+	db.mu.Lock()
+	db.users = newdb.users
+	db.finfo = newdb.finfo
+	db.mu.Unlock()
+
+	return warnings, 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 {
+	buf := new(bytes.Buffer)
+	buf.WriteString("#chasquid-userdb-v1\n")
+
+	db.mu.RLock()
+	defer db.mu.RUnlock()
+
+	// TODO: Sort the usernames, just to be friendlier.
+	for _, user := range db.users {
+		if strings.ContainsAny(user.name, illegalUsernameChars) {
+			return InvalidUsernameErr
+		}
+		fmt.Fprintf(buf, "%s %s %s\n",
+			user.name, user.scheme.String(),
+			base64.StdEncoding.EncodeToString([]byte(user.password)))
+	}
+
+	return safeio.WriteFile(db.fname, buf.Bytes(), db.finfo.Mode())
+}
+
+// Does this user exist in the database?
+func (db *DB) Exists(user string) bool {
+	db.mu.RLock()
+	_, ok := 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()
+	u, ok := db.users[name]
+	db.mu.RUnlock()
+
+	if !ok {
+		return false
+	}
+
+	return u.scheme.PasswordMatches(plainPassword, u.password)
+}
+
+// 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 InvalidUsernameErr
+	}
+
+	s := scryptScheme{
+		// 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)
+	}
+
+	encrypted, err := scrypt.Key([]byte(plainPassword), s.salt,
+		1<<s.logN, s.r, s.p, s.keyLen)
+	if err != nil {
+		return fmt.Errorf("scrypt failed: %v", err)
+	}
+
+	db.mu.Lock()
+	db.users[name] = user{
+		name:     name,
+		scheme:   s,
+		password: string(encrypted),
+	}
+	db.mu.Unlock()
+
+	return nil
+}
+
+///////////////////////////////////////////////////////////
+// Encryption schemes
+//
+
+type scheme interface {
+	String() string
+	PasswordMatches(plain, encrypted string) bool
+}
+
+// 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.
+type plainScheme struct{}
+
+func (s plainScheme) String() string {
+	return "PLAIN"
+}
+
+func (s plainScheme) PasswordMatches(plain, encrypted string) bool {
+	return plain == encrypted
+}
+
+// scrypt scheme, which we use by default.
+type scryptScheme struct {
+	logN   uint // 1<<logN requires this to be uint
+	r, p   int
+	keyLen int
+	salt   []byte
+}
+
+func (s scryptScheme) String() string {
+	// We're encoding the salt in base64, which uses "/+=", and the URL
+	// variant uses "-_=". We use standard encoding, but shouldn't use any of
+	// those as separators, just to be safe.
+	// It's important that the salt be last, as we can only scan
+	// space-delimited strings.
+	return fmt.Sprintf("SCRYPT@n:%d,r:%d,p:%d,l:%d,%s",
+		s.logN, s.r, s.p, s.keyLen,
+		base64.StdEncoding.EncodeToString(s.salt))
+}
+
+func (s scryptScheme) PasswordMatches(plain, encrypted string) bool {
+	dk, err := scrypt.Key([]byte(plain), s.salt, 1<<s.logN, s.r, s.p, 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(encrypted))
+}
+
+func schemeFromString(s string) (scheme, error) {
+	if s == "PLAIN" {
+		return plainScheme{}, nil
+	} else if strings.HasPrefix(s, "SCRYPT@") {
+		sc := scryptScheme{}
+		var b64salt string
+		n, err := fmt.Sscanf(s, "SCRYPT@n:%d,r:%d,p:%d,l:%d,%s",
+			&sc.logN, &sc.r, &sc.p, &sc.keyLen, &b64salt)
+		if n != 5 || err != nil {
+			return nil, fmt.Errorf("error scanning scrypt: %d %v", n, err)
+		}
+		sc.salt, err = base64.StdEncoding.DecodeString(b64salt)
+		if err != nil {
+			return nil, fmt.Errorf("error decoding salt: %v", err)
+		}
+
+		// Perform some sanity checks on the parameters, just in case.
+		if (sc.logN >= 32) || (sc.r*sc.p >= 1<<30) || (sc.keyLen < 24) {
+			return nil, fmt.Errorf("invalid scrypt parameters")
+		}
+
+		return sc, nil
+	}
+
+	return nil, fmt.Errorf("unknown scheme")
+}
diff --git a/internal/userdb/userdb_test.go b/internal/userdb/userdb_test.go
new file mode 100644
index 0000000..b5cfe3c
--- /dev/null
+++ b/internal/userdb/userdb_test.go
@@ -0,0 +1,282 @@
+package userdb
+
+import (
+	"io/ioutil"
+	"os"
+	"testing"
+)
+
+// Remove the file if the test was successful. Used in defer statements, to
+// leave files around for inspection when the tests failed.
+func removeIfSuccessful(t *testing.T, fname string) {
+	if !t.Failed() {
+		os.Remove(fname)
+	}
+}
+
+// Create a database with the given content on a temporary filename. Return
+// the filename, or an error if there were errors creating it.
+func mustCreateDB(t *testing.T, content string) string {
+	f, err := ioutil.TempFile("", "userdb_test")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := f.WriteString(content); err != nil {
+		t.Fatal(err)
+	}
+
+	t.Logf("file: %q", f.Name())
+	return f.Name()
+}
+
+func dbEquals(a, b *DB) bool {
+	if a.users == nil || b.users == nil {
+		return a.users == nil && b.users == nil
+	}
+
+	if len(a.users) != len(b.users) {
+		return false
+	}
+
+	for k, av := range a.users {
+		bv, ok := b.users[k]
+		if !ok || av != bv {
+			return false
+		}
+	}
+
+	return true
+}
+
+var emptyDB = &DB{
+	users: map[string]user{},
+}
+
+const (
+	scryptNoSalt = ("#chasquid-userdb-v1\n" +
+		"user1 SCRYPT@n:14,r:8,p:1,l:32, " +
+		"WyZPRd08NPAkWgBuqB5kwK4fEuB6FHu/X1pA1SxnXhc=")
+	scryptInvalidSalt = ("#chasquid-userdb-v1\n" +
+		"user1 SCRYPT@n:99,r:8,p:1,l:16,not-valid$base64!nono== " +
+		"WyZPRd08NPAkWgBuqB5kwK4fEuB6FHu/X1pA1SxnXhc=")
+	scryptMissingR = ("#chasquid-userdb-v1\n" +
+		"user1 SCRYPT@n:14,r:,p:1,l:32,gY3a3PIzehu7xu6KM9PeOQ== " +
+		"WyZPRd08NPAkWgBuqB5kwK4fEuB6FHu/X1pA1SxnXhc=")
+	scryptBadN = ("#chasquid-userdb-v1\n" +
+		"user1 SCRYPT@n:99,r:8,p:1,l:32,gY3a3PIzehu7xu6KM9PeOQ== " +
+		"WyZPRd08NPAkWgBuqB5kwK4fEuB6FHu/X1pA1SxnXhc=")
+	scryptShortKeyLen = ("#chasquid-userdb-v1\n" +
+		"user1 SCRYPT@n:99,r:8,p:1,l:16,gY3a3PIzehu7xu6KM9PeOQ== " +
+		"WyZPRd08NPAkWgBuqB5kwK4fEuB6FHu/X1pA1SxnXhc=")
+)
+
+// Test various cases of loading an empty/broken database.
+func TestLoad(t *testing.T) {
+	cases := []struct {
+		desc     string
+		content  string
+		fatal    bool
+		fatalErr error
+		warns    bool
+	}{
+		{"empty file", "", false, nil, false},
+		{"header \\n", "#chasquid-userdb-v1\n", false, nil, false},
+		{"header \\r\\n", "#chasquid-userdb-v1\r\n", false, nil, false},
+		{"header EOF", "#chasquid-userdb-v1", false, nil, false},
+		{"missing header", "this is not the header",
+			true, MissingHeaderErr, false},
+		{"invalid user", "#chasquid-userdb-v1\nnam\xa0e PLAIN pass\n",
+			false, nil, true},
+		{"too few fields", "#chasquid-userdb-v1\nfield1 field2\n",
+			false, nil, true},
+		{"too many fields", "#chasquid-userdb-v1\nf1 f2 f3 f4\n",
+			false, nil, true},
+		{"unknown scheme", "#chasquid-userdb-v1\nuser SCHEME pass\n",
+			false, nil, true},
+		{"scrypt no salt", scryptNoSalt, false, nil, true},
+		{"scrypt invalid salt", scryptInvalidSalt, false, nil, true},
+		{"scrypt missing R", scryptMissingR, false, nil, true},
+		{"scrypt bad N", scryptBadN, false, nil, true},
+		{"scrypt short key len", scryptShortKeyLen, false, nil, true},
+	}
+
+	for _, c := range cases {
+		testOneLoad(t, c.desc, c.content, c.fatal, c.fatalErr, c.warns)
+	}
+}
+
+func testOneLoad(t *testing.T, desc, content string, fatal bool, fatalErr error, warns bool) {
+	fname := mustCreateDB(t, content)
+	defer removeIfSuccessful(t, fname)
+	db, warnings, err := Load(fname)
+	if fatal {
+		if err == nil {
+			t.Errorf("case %q: expected error loading, got nil", desc)
+		}
+		if fatalErr != nil && fatalErr != err {
+			t.Errorf("case %q: expected error %v, got %v", desc, fatalErr, err)
+		}
+	} else if !fatal && err != nil {
+		t.Fatalf("case %q: error loading database: %v", desc, err)
+	}
+
+	if warns && warnings == nil {
+		t.Errorf("case %q: expected warnings, got nil", desc)
+	} else if !warns {
+		for _, w := range warnings {
+			t.Errorf("case %q: warning loading database: %v", desc, w)
+		}
+	}
+
+	if db != nil && !dbEquals(db, emptyDB) {
+		t.Errorf("case %q: DB not empty: %#v", db)
+	}
+}
+
+func mustLoad(t *testing.T, fname string) *DB {
+	db, warnings, err := Load(fname)
+	for _, w := range warnings {
+		t.Errorf("warning loading database: %v", w)
+	}
+	if err != nil {
+		t.Fatalf("error loading database: %v", err)
+	}
+
+	return db
+}
+
+func TestWrite(t *testing.T) {
+	fname := mustCreateDB(t, "")
+	defer removeIfSuccessful(t, fname)
+	db := mustLoad(t, fname)
+
+	if err := db.Write(); err != nil {
+		t.Fatalf("error writing database: %v", err)
+	}
+
+	// Load again, check it works and it's still empty.
+	db = mustLoad(t, fname)
+	if !dbEquals(emptyDB, db) {
+		t.Fatalf("expected %v, got %v", emptyDB, db)
+	}
+
+	// Add two users, write, and load again.
+	if err := db.AddUser("user1", "passwd1"); err != nil {
+		t.Fatalf("failed to add user1: %v", err)
+	}
+	if err := db.AddUser("ñoño", "añicos"); err != nil {
+		t.Fatalf("failed to add ñoño: %v", err)
+	}
+	if err := db.Write(); err != nil {
+		t.Fatalf("error writing database: %v", err)
+	}
+
+	db = mustLoad(t, fname)
+	for _, name := range []string{"user1", "ñoño"} {
+		if !db.Exists(name) {
+			t.Errorf("user %q not in database", name)
+		}
+		if _, ok := db.users[name].scheme.(scryptScheme); !ok {
+			t.Errorf("user %q not using scrypt: %#v", name, db.users[name])
+		}
+	}
+
+	// Check various user and password combinations, not all valid.
+	combinations := []struct {
+		user, passwd string
+		expected     bool
+	}{
+		{"user1", "passwd1", true},
+		{"user1", "passwd", false},
+		{"user1", "passwd12", false},
+		{"ñoño", "añicos", true},
+		{"ñoño", "anicos", false},
+		{"notindb", "something", false},
+		{"", "", false},
+		{" ", "  ", false},
+	}
+	for _, c := range combinations {
+		if db.Authenticate(c.user, c.passwd) != c.expected {
+			t.Errorf("auth(%q, %q) != %v", c.user, c.passwd, c.expected)
+		}
+	}
+}
+
+func TestInvalidUsername(t *testing.T) {
+	fname := mustCreateDB(t, "")
+	defer removeIfSuccessful(t, fname)
+	db := mustLoad(t, fname)
+
+	names := []string{
+		" ", "  ", "a b", "ñ ñ", "a\xa0b", "a\x85b", "a\nb", "a\tb", "a\xffb"}
+	for _, name := range names {
+		err := db.AddUser(name, "passwd")
+		if err == nil {
+			t.Errorf("AddUser(%q) worked, expected it to fail", name)
+		}
+	}
+
+	// Add an invalid user from behind, and check that Write fails.
+	db.users["in valid"] = user{"in valid", plainScheme{}, "password"}
+	err := db.Write()
+	if err == nil {
+		t.Errorf("Write worked, expected it to fail")
+	}
+}
+
+// Test the plain scheme. Note we don't expect to use it in cases other than
+// debugging, but it should be functional for that purpose.
+func TestPlainScheme(t *testing.T) {
+	fname := mustCreateDB(t, "")
+	defer removeIfSuccessful(t, fname)
+	db := mustLoad(t, fname)
+
+	db.users["user"] = user{"user", plainScheme{}, "pass word"}
+	err := db.Write()
+	if err != nil {
+		t.Errorf("Write failed: %v", err)
+	}
+
+	db = mustLoad(t, fname)
+	if !db.Authenticate("user", "pass word") {
+		t.Errorf("failed plain authentication")
+	}
+	if db.Authenticate("user", "wrong") {
+		t.Errorf("plain authentication worked but it shouldn't")
+	}
+}
+
+func TestReload(t *testing.T) {
+	content := "#chasquid-userdb-v1\nu1 PLAIN pass\n"
+	fname := mustCreateDB(t, content)
+	defer removeIfSuccessful(t, fname)
+	db := mustLoad(t, fname)
+
+	// Add some things to the file, including a broken line.
+	content += "u2 UNKNOWN pass\n"
+	content += "u3 PLAIN pass\n"
+	ioutil.WriteFile(fname, []byte(content), db.finfo.Mode())
+
+	warnings, err := db.Reload()
+	if err != nil {
+		t.Errorf("Reload failed: %v", err)
+	}
+	if len(warnings) != 1 {
+		t.Errorf("expected 1 warning, got %v", warnings)
+	}
+	if len(db.users) != 2 {
+		t.Errorf("expected 2 users, got %d", len(db.users))
+	}
+
+	// Cause an error loading, check the database is not changed.
+	db.fname = "/does/not/exist"
+	warnings, err = db.Reload()
+	if err == nil {
+		t.Errorf("expected error, got nil")
+	}
+	if len(db.users) != 2 {
+		t.Errorf("expected 2 users, got %d", len(db.users))
+	}
+
+}