Mercurial > go > multipass
changeset 0:c18bc7b9d1d9
Basic binaries. checkpassword doesn't yet work.
author | Paul Fisher <paul@pfish.zone> |
---|---|
date | Sat, 24 Oct 2015 21:32:03 -0400 |
parents | |
children | faf4aad86fc9 |
files | auth/auth.go auth/auth_test.go file/file.go file/paths.go multipass-add.go multipass-checkpassword.go |
diffstat | 6 files changed, 728 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth/auth.go Sat Oct 24 21:32:03 2015 -0400 @@ -0,0 +1,147 @@ +// Package auth contains data structures for authenticating users. + +package auth + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "math/big" + "strconv" + "strings" + + "golang.org/x/crypto/bcrypt" +) + +const ( + // default cost stolen from python bcrypt + bcryptCost = 12 + // we only generate passwords from lowercases for non-ambiguity + lowercases = "abcdefghijklmnopqrstuvwxyz" + template = "____-____-____-____" +) + +var ( + lowercaseLen *big.Int = big.NewInt(int64(len(lowercases))) + maxID = big.NewInt(0) +) + +var ( + ShortEntryError error = errors.New("multipass/auth: password entry must have 3 or more fields") + BadIDError = errors.New("multipass/auth: ID field invalid") + Base64Error = errors.New("multipass/auth: can't decode base64 data") + LongDescriptionError = errors.New("multipass/auth: description must be less than 255 bytes") +) + +func init() { + one := big.NewInt(1) + maxID.Lsh(one, 64) +} + +// Entry represents a single entry in the a multipass file. +type Entry struct { + id uint64 + hash string + description string + rest []string +} + +// EntryFromShadow creates a new entry from a line in a multipass shadow file. +// The line should not end in a newline. +func EntryFromShadow(shadow string) (*Entry, error) { + segments := strings.Split(shadow, ":") + if len(segments) < 2 { + return nil, ShortEntryError + } + entry := new(Entry) + id, err := strconv.ParseUint(segments[0], 10, 64) + if err != nil { + return nil, BadIDError + } + entry.id = id + entry.hash = segments[1] + if len(segments) > 2 { + description, err := base64.StdEncoding.DecodeString(segments[2]) + if err != nil { + return nil, Base64Error + } + entry.description = string(description) + entry.rest = segments[3:] + } + return entry, nil +} + +// NewEntry creates an Entry for the given description. +// It returns the Entry itself and a generated password. +func NewEntry(description string) (entry *Entry, password string, err error) { + if len(description) > 255 { + return nil, "", LongDescriptionError + } + passBytes := genPassword() + password = string(passBytes) + hashBytes, err := bcrypt.GenerateFromPassword(passBytes, bcryptCost) + if err != nil { + // This is very unexpected. + return nil, "", err + } + e := new(Entry) + e.id = newID() + e.hash = string(hashBytes) + e.description = description + return e, password, nil +} + +// ID is a unique 64-bit integer which identifies the entry. +func (e *Entry) ID() uint64 { + return e.id +} + +// Description is the user's description of their password. +func (e *Entry) Description() string { + return e.description +} + +// Authenticate tests whether the password is correct. +func (e *Entry) Authenticate(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(e.hash), []byte(password)) + return err == nil +} + +// Encode encodes this Entry to a bytestring for writing to a multipass shadow file. +func (e *Entry) Encode() string { + segments := []string{ + strconv.FormatUint(e.id, 10), + e.hash, + base64.StdEncoding.EncodeToString([]byte(e.description)), + } + segments = append(segments, e.rest...) + return strings.Join(segments, ":") +} + +func genPassword() []byte { + password := []byte(template) + for group := 0; group < 4; group++ { + base := group * 5 + for chr := 0; chr < 4; chr++ { + password[base+chr] = randChr() + } + } + return password +} + +func randChr() byte { + bigIdx, err := rand.Int(rand.Reader, lowercaseLen) + if err != nil { + panic("multipass/auth: can't get a random number") + } + idx := bigIdx.Int64() + return byte(lowercases[idx]) +} + +func newID() uint64 { + bigID, err := rand.Int(rand.Reader, maxID) + if err != nil { + panic("multipass/auth: can't get a random number") + } + return bigID.Uint64() +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/auth/auth_test.go Sat Oct 24 21:32:03 2015 -0400 @@ -0,0 +1,257 @@ +package auth + +import ( + "regexp" + "testing" +) + +var passPattern *regexp.Regexp = regexp.MustCompile(`^(?:[a-z]{4}-){3}[a-z]{4}$`) + +const basicShadow = "jhh:9999:$2a$12$tcv2MrtXgibAJHsSwVfHiOevXBFmiGy0HTNoOB8QzIhEh46iWS1uC:YW55dGhpbmcgbW9yZSB0aGFuIDUgcmVwcyBpcyBjYXJkaW8=" +const extraDataShadow = "skw:1:$2a$12$lINQdYWHOcLKoqhNOr3mNOpZSAu5JOBS2F7T/VDfYn2rvv6qUJehG::additional:fields" + +func TestEntryFromShadow(t *testing.T) { + cases := []struct { + shadow string + wantErr bool + username string + id uint64 + hash string + description string + rest []string + }{ + { + shadow: "pfish:1234:$2a$12$apFtWGXKtWBavVy5eo.22Ohs43GudT5IYTqyQkIBX9LpS7YtvKBpa", + username: "pfish", + id: 1234, + hash: "$2a$12$apFtWGXKtWBavVy5eo.22Ohs43GudT5IYTqyQkIBX9LpS7YtvKBpa", + }, + { + shadow: basicShadow, + username: "jhh", + id: 9999, + hash: "$2a$12$tcv2MrtXgibAJHsSwVfHiOevXBFmiGy0HTNoOB8QzIhEh46iWS1uC", + description: "anything more than 5 reps is cardio", + }, + { + shadow: extraDataShadow, + username: "skw", + id: 1, + hash: "$2a$12$lINQdYWHOcLKoqhNOr3mNOpZSAu5JOBS2F7T/VDfYn2rvv6qUJehG", + rest: []string{"additional", "fields"}, + }, + { + shadow: "user:one:bogushash", + wantErr: true, + }, + { + shadow: "user:-1:bogushash", + wantErr: true, + }, + { + shadow: "tooshort", + wantErr: true, + }, + { + shadow: "test:0:bogushash:invalid base64", + wantErr: true, + }, + } + for _, c := range cases { + entry, err := EntryFromShadow(c.shadow) + if c.wantErr { + if err == nil { + t.Errorf("EntryFromShadow(%q) == _, nil; want non-nil err", c.shadow) + } + continue + } + if err != nil { + t.Errorf("EntryFromShadow(%q) == _, %q; want nil err", c.shadow, err) + } + if c.username != entry.username { + t.Errorf("EntryFromShadow(%q).username = %q; want %q", c.shadow, entry.username, c.username) + } + if c.hash != string(entry.hash) { + t.Errorf("EntryFromShadow(%q).password = %q; want %q", c.shadow, entry.hash, c.hash) + } + if c.description != entry.description { + t.Errorf("EntryFromShadow(%q).description = %q; want %q", c.shadow, entry.description, c.description) + } + restEqual := false + if len(c.rest) == len(entry.rest) { + restEqual = true + for i := range c.rest { + if c.rest[i] != entry.rest[i] { + restEqual = false + break + } + } + } + if !restEqual { + t.Errorf("EntryFromShadow(%q).rest = %q; want %q", c.shadow, entry.rest, c.rest) + } + } +} + +func TestNewEntry(t *testing.T) { + cases := []struct { + username string + description string + wantErr bool + }{ + {"pfish", "one", false}, + {"pfish", "the other", false}, + {"with:colons", "", true}, + {"pfish", string(make([]byte, 1000)), true}, + } + for _, c := range cases { + entry, password, err := NewEntry(c.username, c.description) + if c.wantErr { + if err == nil { + t.Errorf("NewEntry(%q, %q) = _, _, nil; want non-nil err", c.username, c.description) + } + continue + } + if err != nil { + t.Errorf("NewEntry(%q, %q) = _, _, %q; want nil err", c.username, c.description, err) + } + if c.username != entry.username { + t.Errorf("NewEntry(%q, %q).username = %q, want %q", + c.username, c.description, entry.username, c.username) + } + if entry.id == 0 { + // This test has a 1/(2**64) chance of failing! :o + t.Errorf("NewEntry(_, _).id == 0, want nonzero") + } + if c.description != entry.description { + t.Errorf("NewEntry(%q, %q).description = %q, want %q", + c.username, c.description, entry.description, c.description) + } + if !passPattern.MatchString(password) { + t.Errorf("NewEntry(_, _) = _, %q, _; wanted to match xxxx-xxxx-xxxx-xxxx", password) + } + if !entry.Authenticate(c.username, password) { + t.Errorf("NewEntry(%q, %q).Authenticate(%q, %q) failed", + c.username, c.description, c.username, password) + } + } +} + +func TestGenPassword(t *testing.T) { + p := genPassword() + if !passPattern.MatchString(string(p)) { + t.Errorf("genPassword() = %q; wanted to match xxxx-xxxx-xxxx-xxxx", p) + } +} + +func TestAuthenticate(t *testing.T) { + entry, password, err := NewEntry("pfish", "") + if err != nil { + t.Errorf("Error building entry") + } + type testcase struct { + username, password string + want bool + } + + cases := []testcase{ + {"pfish", password, true}, + {"jhh", password, false}, + {"pfish", "not the password", false}, + {"jhh", "not the password", false}, + } + for _, c := range cases { + got := entry.Authenticate(c.username, c.password) + if got != c.want { + t.Errorf("entry.Authenticate(%q, %q) == %q, want %q", + c.username, c.password, got, c.want) + } + } + + entry, err = EntryFromShadow(basicShadow) + if err != nil { + t.Errorf("Error loading valid shadow") + } + + cases = []testcase{ + {"jhh", "nocardio", true}, + {"pfish", "nocardio", false}, + {"jhh", "not the password", false}, + {"pfish", "not the password", false}, + } + for _, c := range cases { + got := entry.Authenticate(c.username, c.password) + if got != c.want { + t.Errorf("entry.Authenticate(%q, %q) == %q, want %q", + c.username, c.password, got, c.want) + } + } +} + +func testMatchesID(t *testing.T) { + entry, err := EntryFromShadow(basicShadow) + if err != nil { + t.Errorf("Error loading valid shadow") + } + cases := []struct { + username string + id uint64 + want bool + }{ + {"jhh", 1234, true}, + {"pfish", 1234, false}, + {"jhh", 9999, false}, + {"pfish", 9999, false}, + } + for _, c := range cases { + got := entry.MatchesID(c.username, c.id) + if got != c.want { + t.Errorf("entry.MatchesID(%q, %q) == %q, want %q", + c.username, c.id, got, c.want) + } + } +} + +func TestEncode(t *testing.T) { + // Crafted entry + shadowed, err := EntryFromShadow(basicShadow) + if err != nil { + t.Errorf("Error loading valid shadow") + } + extraShadowed, err := EntryFromShadow(extraDataShadow) + if err != nil { + t.Errorf("Error loading valid shadow") + } + cases := []struct { + entry *Entry + want string + }{ + { + &Entry{ + username: "testuser", + id: 6775, + hash: "bogushash", + description: "something", + }, + "testuser:6775:bogushash:c29tZXRoaW5n", + }, + { + &Entry{ + username: "testuser", + id: 6775, + hash: "bogushash", + description: "something", + rest: []string{"a", "B"}, + }, + "testuser:6775:bogushash:c29tZXRoaW5n:a:B", + }, + {shadowed, basicShadow}, + {extraShadowed, extraDataShadow}, + } + for _, c := range cases { + got := string(c.entry.Encode()) + if got != c.want { + t.Errorf("entry.Encode() = %q, want %q", got, c.want) + } + } +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/file/file.go Sat Oct 24 21:32:03 2015 -0400 @@ -0,0 +1,239 @@ +// Package file handles I/O for single password files. +// +// A password file contains multiple passwords for a single user. +// It starts with a banner that indicates the version of the file, +// then has entries in the format specified by auth.Entry. + +package file + +import ( + "bufio" + "errors" + "os" + "os/user" + "syscall" + + "path" + + "golang.org/x/sys/unix" + "pfish.zone/go/multipass/auth" +) + +const ( + // the Banner acts as a version indicator + Banner = "# Multipass v0.1 password file" +) + +var ( + // Raised when + ErrorBadFile = errors.New("multipass/file: Invalid file format") +) + +type ShadowFile struct { + name string +} + +// ForUser gets the given user's ShadowFile. +func ForUser(username string) (*ShadowFile, error) { + u, err := user.Lookup(username) + if err != nil { + return nil, err + } + return New(path.Join(u.HomeDir, MultipassFile)), nil +} + +// ForMe gets the current user's ShadowFile. +func ForMe() (*ShadowFile, error) { + u, err := user.Current() + if err != nil { + return nil, err + } + return New(path.Join(u.HomeDir, MultipassFile)), nil +} + +// New creates a ShadowFile for reading at the given path. +// If a file needs to be created, uses the given GID to create it. +func New(name string) *ShadowFile { + f := new(ShadowFile) + f.name = name + return f +} + +func (f *ShadowFile) newFilename() string { + return f.name + ".new" +} + +func (f *ShadowFile) open() (*os.File, *bufio.Scanner, error) { + file, err := os.Open(f.name) + if err != nil { + return nil, nil, err + } + scanner := bufio.NewScanner(file) + if !scanner.Scan() { + file.Close() + return nil, nil, err + } + if scanner.Text() != Banner { + file.Close() + return nil, nil, ErrorBadFile + } + return file, scanner, nil +} + +func (f *ShadowFile) Authenticate(password string) (bool, error) { + file, scanner, err := f.open() + if err != nil { + return false, err + } + defer file.Close() + + for scanner.Scan() { + entry, err := auth.EntryFromShadow(scanner.Text()) + // Skip invalid lines. + if err != nil { + continue + } + if entry.Authenticate(password) { + return true, nil + } + } + return false, nil +} + +func (f *ShadowFile) Add(entry *auth.Entry) error { + handle, err := f.openWrite() + if err != nil { + return err + } + err = handle.write(entry) + if err != nil { + handle.bail() + return err + } + for handle.next() { + if entry, err := handle.entry(); err == nil { + // If we get an invalid entry, just skip it. + if err := handle.write(entry); err != nil { + handle.bail() + return err + } + } + } + return handle.finalize() +} + +func (f *ShadowFile) openWrite() (*writeHandle, error) { + return openWriteHandle(f.newFilename(), f.name) +} + +type writeHandle struct { + // We write to a file handle pointing to tempName. + tempName string + tempFile *os.File + writer *bufio.Writer + + // It will eventually move to fileName, which is our source file. + fileName string + // If inFile is nil, we're creating a new multipass file. + inFile *os.File + scanner *bufio.Scanner +} + +func openWriteHandle(tempName, fileName string) (*writeHandle, error) { + h := new(writeHandle) + h.tempName = tempName + h.fileName = fileName + // Open the output file, readable only by the current user. + oldUmask := unix.Umask(077) + tempFile, err := os.Create(tempName) + unix.Umask(oldUmask) + if err != nil { + return nil, err + } + h.tempFile = tempFile + // Open the input file. + inFile, err := os.Open(fileName) + if err == nil { + // Prepare to read in the input file, if it exists. + h.inFile = inFile + h.scanner = bufio.NewScanner(h.inFile) + if !h.scanner.Scan() { + return nil, ErrorBadFile + } + if h.scanner.Text() != Banner { + return nil, ErrorBadFile + } + // Change the owner and group of the new file to that of the old. + inStat, err := h.inFile.Stat() + if err != nil { + h.bail() + return nil, err + } + inStatUnix := inStat.Sys().(*syscall.Stat_t) + if err := h.tempFile.Chown(int(inStatUnix.Uid), int(inStatUnix.Gid)); err != nil { + h.bail() + return nil, err + } + if err := h.tempFile.Chmod(inStat.Mode()); err != nil { + h.bail() + return nil, err + } + } + // TODO(pfish): If there is no input file, set the right permissions + group on the output file. + h.writer = bufio.NewWriter(h.tempFile) + if _, err := h.writer.WriteString(Banner + "\n"); err != nil { + return nil, err + } + return h, nil +} + +func (h *writeHandle) bail() { + h.tempFile.Close() + h.inFile.Close() + os.Remove(h.tempName) +} + +func (h *writeHandle) next() bool { + // If the scanner is nil, then we have no input file and therefore no next. + return h.scanner != nil && h.scanner.Scan() +} + +func (h *writeHandle) entry() (*auth.Entry, error) { + return auth.EntryFromShadow(h.scanner.Text()) +} + +func (h *writeHandle) write(entry *auth.Entry) error { + if _, err := h.writer.WriteString(entry.Encode()); err != nil { + return err + } + _, err := h.writer.WriteString("\n") + return err +} + +func (h *writeHandle) finalize() error { + h.inFile.Close() + // Make absolutely completely sure we're synced. + if err := h.writer.Flush(); err != nil { + h.tempFile.Close() + os.Remove(h.tempName) + return err + } + if err := h.tempFile.Sync(); err != nil { + h.tempFile.Close() + os.Remove(h.tempName) + return err + } + // Close the file. + if err := h.tempFile.Close(); err != nil { + os.Remove(h.tempName) + return err + } + // And atomically write it over the new one. + err := os.Rename(h.tempName, h.fileName) + if err != nil { + return err + } + // If we get here, everything succeeded, and only in this case + // will the multipass shadow file have been updated. + return nil +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/file/paths.go Sat Oct 24 21:32:03 2015 -0400 @@ -0,0 +1,5 @@ +package file + +const ( + MultipassFile = ".multipass" +)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/multipass-add.go Sat Oct 24 21:32:03 2015 -0400 @@ -0,0 +1,30 @@ +// multipass-add allows a user to add an entry to their multipass database. + +package main + +import ( + "bufio" + "fmt" + "os" + + "pfish.zone/go/multipass/auth" + "pfish.zone/go/multipass/file" +) + +func main() { + passfile, err := file.ForMe() + reader := bufio.NewReader(os.Stdin) + fmt.Print("Describe password: ") + text, err := reader.ReadString('\n') + if err != nil { + fmt.Println(err.Error()) + return + } + entry, password, err := auth.NewEntry(text) + if err != nil { + fmt.Println(err.Error()) + return + } + passfile.Add(entry) + fmt.Printf("New password: %s\n", password) +}
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/multipass-checkpassword.go Sat Oct 24 21:32:03 2015 -0400 @@ -0,0 +1,50 @@ +package main + +import ( + "bufio" + "os" + "os/user" + "syscall" + + "pfish.zone/go/multipass/file" +) + +const ( + InternalError = 111 + Failed = 1 +) + +func main() { + //infile = os.NewFile(3, "") + reader := bufio.NewReader(os.Stdin) + username, err := reader.ReadString('\n') + if err != nil { + os.Exit(InternalError) + } + pass, err := reader.ReadString('\n') + if err != nil { + os.Exit(InternalError) + } + passfile, err := file.ForUser(username) + if err != nil { + os.Exit(Failed) + } + success, _ := passfile.Authenticate(pass) + if !success { + os.Exit(Failed) + } + user, err := user.Lookup(username) + err = os.Setenv("USER", user.HomeDir) + if err != nil { + os.Exit(Failed) + } + os.Setenv("userdb_uid", user.Uid) + os.Setenv("userdb_gid", user.Gid) + os.Setenv("EXTRA", "userdb_uid userdb_gid") + environ := []string{ + "userdb_uid=" + user.Uid, + "userdb_gid=" + user.Gid, + "EXTRA=userdb_uid userdb_gid", + } + syscall.Exec(os.Args[1], os.Args[1:], environ) +}