author | Alberto Bertogli
<albertito@blitiri.com.ar> 2016-07-12 01:03:36 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2016-07-16 11:33:50 UTC |
parent | c12fa0eed7a325396237b8c8e27574e09185b447 |
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)) + } + +}