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)
+}