From f7e1f859fd35078e13208c46347d77e604483bef Mon Sep 17 00:00:00 2001 From: Russ Magee Date: Tue, 8 Sep 2020 21:19:00 -0700 Subject: [PATCH] Initial commit --- auth_test.go | 158 +++++++++++++++++++ internal/termmode/termmode_linux.go | 136 ++++++++++++++++ lpasswd.go | 231 ++++++++++++++++++++++++++++ lpasswd/lpasswd.go | 80 ++++++++++ 4 files changed, 605 insertions(+) create mode 100755 auth_test.go create mode 100755 internal/termmode/termmode_linux.go create mode 100755 lpasswd.go create mode 100755 lpasswd/lpasswd.go diff --git a/auth_test.go b/auth_test.go new file mode 100755 index 0000000..6633701 --- /dev/null +++ b/auth_test.go @@ -0,0 +1,158 @@ +package lpasswd + +import ( + "errors" + "fmt" + "io" + "os/user" + "testing" +) + +type userVerifs struct { + user string + passwd string + good bool +} + +var ( + dummyShadowA = `johndoe:$6$EeQlTtn/KXdSh6CW$UHbFuEw3UA0Jg9/GoPHxgWk6Ws31x3IjqsP22a9pVMOte0yQwX1.K34oI4FACu8GRg9DArJ5RyWUE9m98qwzZ1:18310:0:99999:7::: +joebloggs:$6$F.0IXOrb0w0VJHG1$3O4PYyng7F3hlh42mbroEdQZvslybY5etPPiLMQJ1xosjABY.Q4xqAfyIfe03Du61ZjGQIt3nL0j12P9k1fsK/:18310:0:99999:7::: +disableduser:!:18310::::::` + + dummyXsPasswdFile = `#username:salt:authCookie +bobdobbs:$2a$12$9vqGkFqikspe/2dTARqu1O:$2a$12$9vqGkFqikspe/2dTARqu1OuDKCQ/RYWsnaFjmi.HtmECRkxcZ.kBK +notbob:$2a$12$cZpiYaq5U998cOkXzRKdyu:$2a$12$cZpiYaq5U998cOkXzRKdyuJ2FoEQyVLa3QkYdPQk74VXMoAzhvuP6 +` + + testGoodUsers = []userVerifs{ + {"johndoe", "testpass", true}, + {"joebloggs", "testpass2", true}, + {"johndoe", "badpass", false}, + } + + userlookup_arg_u string + readfile_arg_f string +) + +func newMockAuthCtx(reader func(string) ([]byte, error), userlookup func(string) (*user.User, error)) (ret *AuthCtx) { + ret = &AuthCtx{reader, userlookup} + return +} + +func _mock_user_Lookup(username string) (*user.User, error) { // nolint:staticcheck + username = userlookup_arg_u + if username == "baduser" { + return &user.User{}, errors.New("bad user") + } + urec := &user.User{Uid: "1000", Gid: "1000", Username: username, Name: "Full Name", HomeDir: "/home/user"} + fmt.Printf(" [mock user rec:%v]\n", urec) + return urec, nil +} + +func _mock_ioutil_ReadFile(f string) ([]byte, error) { //nolint:staticcheck + f = readfile_arg_f + if f == "/etc/shadow" { + fmt.Println(" [mocking ReadFile(\"/etc/shadow\")]") + return []byte(dummyShadowA), nil + } + if f == "/etc/xs.passwd" { + fmt.Println(" [mocking ReadFile(\"/etc/xs.passwd\")]") + return []byte(dummyXsPasswdFile), nil + } + return []byte{}, errors.New("no readfile_arg_f supplied") +} + +func _mock_ioutil_ReadFileEmpty(f string) ([]byte, error) { + return []byte{}, io.EOF +} + +func _mock_ioutil_ReadFileHasError(f string) ([]byte, error) { // nolint:deadcode,unused + return []byte{}, errors.New("IO Error") +} + +func TestVerifyPass(t *testing.T) { + readfile_arg_f = "/etc/shadow" + ctx := newMockAuthCtx(_mock_ioutil_ReadFile, nil) + for idx, rec := range testGoodUsers { + stat, e := VerifyPass(ctx, rec.user, rec.passwd) + if rec.good && (!stat || e != nil) { + t.Fatalf("failed %d\n", idx) + } + } +} + +func TestVerifyPassFailsOnEmptyFile(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFileEmpty, nil) + stat, e := VerifyPass(ctx, "johndoe", "somepass") + if stat || (e == nil) { + t.Fatal("failed to fail w/empty file") + } +} + +func TestVerifyPassFailsOnFileError(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFileEmpty, nil) + stat, e := VerifyPass(ctx, "johndoe", "somepass") + if stat || (e == nil) { + t.Fatal("failed to fail on ioutil.ReadFile error") + } +} + +func TestVerifyPassFailsOnDisabledEntry(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFileEmpty, nil) + stat, e := VerifyPass(ctx, "disableduser", "!") + if stat || (e == nil) { + t.Fatal("failed to fail on disabled user entry") + } +} + +//// + +func TestAuthUserByPasswdFailsOnEmptyFile(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFileEmpty, _mock_user_Lookup) + userlookup_arg_u = "bobdobbs" + readfile_arg_f = "/etc/xs.passwd" + stat, _ := AuthUserByPasswd(ctx, false, userlookup_arg_u, "praisebob", readfile_arg_f) + if stat { + t.Fatal("failed to fail with missing xs.passwd file") + } +} + +func TestAuthUserByPasswdFailsOnBadAuth(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFile, _mock_user_Lookup) + userlookup_arg_u = "bobdobbs" + readfile_arg_f = "/etc/xs.passwd" + stat, _ := AuthUserByPasswd(ctx, false, userlookup_arg_u, "wrongpass", readfile_arg_f) + if stat { + t.Fatal("failed to fail with valid user, incorrect passwd in xs.passwd file") + } +} + +func TestAuthUserByPasswdFailsOnBadUser(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFile, _mock_user_Lookup) + userlookup_arg_u = "bobdobbs" + readfile_arg_f = "/etc/xs.passwd" + stat, _ := AuthUserByPasswd(ctx, false, userlookup_arg_u, "theotherbob", readfile_arg_f) + if stat { + t.Fatal("failed to fail on invalid user vs. xs.passwd file") + } +} + +func TestAuthUserByPasswdPassesOnGoodAuth(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFile, _mock_user_Lookup) + userlookup_arg_u = "bobdobbs" + readfile_arg_f = "/etc/xs.passwd" + stat, _ := AuthUserByPasswd(ctx, false, userlookup_arg_u, "praisebob", readfile_arg_f) + if !stat { + t.Fatal("failed on valid user w/correct passwd in xs.passwd file") + } +} + +func TestAuthUserByPasswdPassesOnOtherGoodAuth(t *testing.T) { + ctx := newMockAuthCtx(_mock_ioutil_ReadFile, _mock_user_Lookup) + userlookup_arg_u = "notbob" + readfile_arg_f = "/etc/xs.passwd" + stat, _ := AuthUserByPasswd(ctx, false, userlookup_arg_u, "imposter", readfile_arg_f) + if !stat { + t.Fatal("failed on valid user 2nd entry w/correct passwd in xs.passwd file") + } +} diff --git a/internal/termmode/termmode_linux.go b/internal/termmode/termmode_linux.go new file mode 100755 index 0000000..4f10a33 --- /dev/null +++ b/internal/termmode/termmode_linux.go @@ -0,0 +1,136 @@ +// +build linux + +package termmode + +import ( + "errors" + "io" + + unix "golang.org/x/sys/unix" +) + +/* ------------- + * minimal terminal APIs brought in from ssh/terminal + * (they have no real business being there as they aren't specific to + * ssh, but as of Go v1.10, late 2019, core go stdlib hasn't yet done + * the planned terminal lib reorgs.) + * ------------- */ + +// From github.com/golang/crypto/blob/master/ssh/terminal/util_linux.go +const ioctlReadTermios = unix.TCGETS +const ioctlWriteTermios = unix.TCSETS + +// From github.com/golang/crypto/blob/master/ssh/terminal/util.go + +// State contains the state of a terminal. +type State struct { + termios unix.Termios +} + +// MakeRaw put the terminal connected to the given file descriptor into raw +// mode and returns the previous state of the terminal so that it can be +// restored. +func MakeRaw(fd uintptr) (*State, error) { + termios, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + if err != nil { + return nil, err + } + + oldState := State{termios: *termios} + + // This attempts to replicate the behaviour documented for cfmakeraw in + // the termios(3) manpage. + termios.Iflag &^= unix.IGNBRK | unix.BRKINT | unix.PARMRK | unix.ISTRIP | unix.INLCR | unix.IGNCR | unix.ICRNL | unix.IXON + termios.Oflag &^= unix.OPOST + termios.Lflag &^= unix.ECHO | unix.ECHONL | unix.ICANON | unix.ISIG | unix.IEXTEN + termios.Cflag &^= unix.CSIZE | unix.PARENB + termios.Cflag |= unix.CS8 + termios.Cc[unix.VMIN] = 1 + termios.Cc[unix.VTIME] = 0 + if err := unix.IoctlSetTermios(int(fd), ioctlWriteTermios, termios); err != nil { + return nil, err + } + + return &oldState, nil +} + +// GetState returns the current state of a terminal which may be useful to +// restore the terminal after a signal. +func GetState(fd uintptr) (*State, error) { + termios, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + if err != nil { + return nil, err + } + + return &State{termios: *termios}, nil +} + +// Restore restores the terminal connected to the given file descriptor to a +// previous state. +func Restore(fd uintptr, state *State) error { + if state != nil { + return unix.IoctlSetTermios(int(fd), ioctlWriteTermios, &state.termios) + } else { + return errors.New("nil State") + } +} + +// ReadPassword reads a line of input from a terminal without local echo. This +// is commonly used for inputting passwords and other sensitive data. The slice +// returned does not include the \n. +func ReadPassword(fd uintptr) ([]byte, error) { + termios, err := unix.IoctlGetTermios(int(fd), ioctlReadTermios) + if err != nil { + return nil, err + } + + newState := *termios + newState.Lflag &^= unix.ECHO + newState.Lflag |= unix.ICANON | unix.ISIG + newState.Iflag |= unix.ICRNL + if err := unix.IoctlSetTermios(int(fd), ioctlWriteTermios, &newState); err != nil { + return nil, err + } + + defer func() { + _ = unix.IoctlSetTermios(int(fd), ioctlWriteTermios, termios) // nolint: gosec + }() + + return readPasswordLine(passwordReader(fd)) +} + +// passwordReader is an io.Reader that reads from a specific file descriptor. +type passwordReader int + +func (r passwordReader) Read(buf []byte) (int, error) { + return unix.Read(int(r), buf) +} + +// readPasswordLine reads from reader until it finds \n or io.EOF. +// The slice returned does not include the \n. +// readPasswordLine also ignores any \r it finds. +func readPasswordLine(reader io.Reader) ([]byte, error) { + var buf [1]byte + var ret []byte + + for { + n, err := reader.Read(buf[:]) + if n > 0 { + switch buf[0] { + case '\n': + return ret, nil + case '\r': + // remove \r from passwords on Windows + default: + ret = append(ret, buf[0]) + } + continue + } + if err != nil { + if err == io.EOF && len(ret) > 0 { + return ret, nil + } + return ret, err + } + } +} diff --git a/lpasswd.go b/lpasswd.go new file mode 100755 index 0000000..24ae2d4 --- /dev/null +++ b/lpasswd.go @@ -0,0 +1,231 @@ +// lpasswd - lib to handle storage and lookup of app-local users +// with passwords akin to /etc/passwd. +// +// Copyright (c) 2017-2020 Russell Magee +// Licensed under the terms of the MIT license (see LICENSE.mit in this +// distribution) +// +// golang implementation by Russ Magee (rmagee_at_gmail.com) +package lpasswd + +import ( + "bytes" + "encoding/csv" + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "os/user" + "runtime" + "strings" + + passlib "gopkg.in/hlandau/passlib.v1" + + "github.com/jameskeane/bcrypt" +) + +type AuthCtx struct { + reader func(string) ([]byte, error) // eg. ioutil.ReadFile() + userlookup func(string) (*user.User, error) // eg. os/user.Lookup() +} + +func NewAuthCtx( /*reader func(string) ([]byte, error), userlookup func(string) (*user.User, error)*/ ) (ret *AuthCtx) { + ret = &AuthCtx{ioutil.ReadFile, user.Lookup} + return +} + +// --------- System passwd/shadow auth routine(s) -------------- + +// VerifyPass verifies a password against system standard shadow file +// Note auxilliary fields for expiry policy are *not* inspected. +func VerifyPass(ctx *AuthCtx, user, password string) (bool, error) { + if ctx.reader == nil { + ctx.reader = ioutil.ReadFile // dependency injection hides that this is required + } + passlib.UseDefaults(passlib.Defaults20180601) //nolint:errcheck + var pwFileName string + if runtime.GOOS == "linux" { + pwFileName = "/etc/shadow" + } else if runtime.GOOS == "freebsd" { + pwFileName = "/etc/master.passwd" + } else { + pwFileName = "unsupported" + } + pwFileData, e := ctx.reader(pwFileName) + if e != nil { + return false, e + } + pwLines := strings.Split(string(pwFileData), "\n") + if len(pwLines) < 1 { + return false, errors.New("Empty shadow file!") + } else { + var line string + var hash string + var idx int + for idx = range pwLines { + line = pwLines[idx] + lFields := strings.Split(line, ":") + if lFields[0] == user { + hash = lFields[1] + break + } + } + if len(hash) == 0 { + return false, errors.New("nil hash!") + } else { + pe := passlib.VerifyNoUpgrade(password, hash) + if pe != nil { + return false, pe + } + } + } + return true, nil +} + +// --------- End System passwd/shadow auth routine(s) ---------- + +// --------- Local passwd auth routine(s) -------------- + +// AuthUserByPasswd checks user login information using a password. +// This checks file _fname_ for auth info, and optionally system /etc/passwd +// to cross-check the user actually exists, if sysacct == true. +// nolint: gocyclo +func AuthUserByPasswd(ctx *AuthCtx, sysacct bool, username string, auth string, fname string) (valid bool, err error) { + if ctx.reader == nil { + ctx.reader = ioutil.ReadFile // dependency injection hides that this is required + } + if ctx.userlookup == nil { + ctx.userlookup = user.Lookup // again for dependency injection as dep is now hidden + } + b, e := ctx.reader(fname) // nolint: gosec + if e != nil { + return false, fmt.Errorf("cannot read %s", fname) + } + r := csv.NewReader(bytes.NewReader(b)) + + r.Comma = ':' + r.Comment = '#' + r.FieldsPerRecord = 3 // username:salt:authCookie + for { + var record []string + record, err = r.Read() + if errors.Is(err, io.EOF) { + // Use dummy entry if user not found + // (prevent user enumeration attack via obvious timing diff; + // ie., not attempting any auth at all) + record = []string{"$nosuchuser$", + "$2a$12$l0coBlRDNEJeQVl6GdEPbU", + "$2a$12$l0coBlRDNEJeQVl6GdEPbUC/xmuOANvqgmrMVum6S4i.EXPgnTXy6"} + username = "$nosuchuser$" + err = nil + } + if err != nil { + return false, err + } + + if username == record[0] { + var tmp string + tmp, err = bcrypt.Hash(auth, record[1]) + if err != nil { + break + } + if tmp == record[2] && username != "$nosuchuser$" { + valid = true + } else { + err = fmt.Errorf("auth failure") + } + break + } + } + // Security scrub + for i := range b { + b[i] = 0 + } + r = nil + runtime.GC() + + if sysacct { + _, userErr := ctx.userlookup(username) + if userErr != nil { + valid = false + } + } + return +} + +// SetPasswd enters _uname_ with the specified _passwd_ into the +// local password file _passwdFName_. +func SetPasswd(uname, passwd, passwdFName string) (e error) { // nolint: gocyclo + if uname == "" { + return errors.New("must specify a username") + } + // generate a random salt with specific rounds of complexity + // (default in jameskeane/bcrypt is 12 but we'll be explicit here) + salt, err := bcrypt.Salt(12) + if err != nil { + return fmt.Errorf("bcrypt.Salt() failed: %w", err) + } + // hash and verify a password with explicit (random) salt + hash, err := bcrypt.Hash(passwd, salt) + if err != nil || !bcrypt.Match(passwd, hash) { + return fmt.Errorf("bcrypt.Match() failed: %w", err) + } + b, err := ioutil.ReadFile(passwdFName) // nolint: gosec + if err != nil { + return fmt.Errorf("ioutil.ReadFile(): %w", err) + } + r := csv.NewReader(bytes.NewReader(b)) + + r.Comma = ':' + r.Comment = '#' + r.FieldsPerRecord = 3 // username:salt:authCookie] + + records, err := r.ReadAll() + if err != nil { + return fmt.Errorf("r.ReadAll(): %w", err) + } + + recFound := false + for i := range records { + if records[i][0] == uname { + recFound = true + records[i][1] = salt + records[i][2] = hash + } + //// csv lib doesn't preserve comment in record, so put it back + //if records[i][0][0] == '!' { + // records[i][0] = "#" + records[i][0] + //} + } + if !recFound { + newRec := []string{uname, salt, hash} + records = append(records, newRec) + } + + outFile, err := ioutil.TempFile("", "xs-passwd") + if err != nil { + return fmt.Errorf("ioutil.TempFile(): %w", err) + } + w := csv.NewWriter(outFile) + w.Comma = ':' + //w.FieldsPerRecord = 4 // username:salt:authCookie:disallowedCmdList (a,b,...) + err = w.Write([]string{"#username", "salt", "authCookie" /*, "disallowedCmdList"*/}) + if err != nil { + return fmt.Errorf("w.Write(): %w", err) + } + err = w.WriteAll(records) + if err != nil { + return fmt.Errorf("w.WriteAll: %w", err) + } + err = os.Remove(passwdFName) + if err != nil { + return fmt.Errorf("os.Remove(): %w", err) + } + err = os.Rename(outFile.Name(), passwdFName) + if err != nil { + return fmt.Errorf("os.Rename(): %w", err) + } + return nil +} +// --------- End Local passwd auth routine(s) -------------- diff --git a/lpasswd/lpasswd.go b/lpasswd/lpasswd.go new file mode 100755 index 0000000..a74cbef --- /dev/null +++ b/lpasswd/lpasswd.go @@ -0,0 +1,80 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "blitter.com/go/lpasswd/internal/termmode" + "blitter.com/go/lpasswd" +) + +var ( + version string = "0.1" + gitCommit string +) + +// nolint: gocyclo +func main() { + var vopt bool + var pfName string + var newpw string + var confirmpw string + var userName string + + flag.BoolVar(&vopt, "v", false, "show version") + flag.StringVar(&userName, "u", "", "username") + flag.StringVar(&pfName, "f", "local.passwd", "passwd file") + flag.Parse() + + if vopt { + if gitCommit != "" { + gitCommit = fmt.Sprintf(" (%s)", gitCommit) + } + fmt.Printf("version %s%s\n", version, gitCommit) + os.Exit(0) + } + + var uname string + if len(userName) == 0 { + log.Println("specify username with -u") + os.Exit(1) + } + + //u, err := user.Lookup(userName) + //if err != nil { + // log.Printf("Invalid user %s\n", userName) + // log.Fatal(err) + //} + //uname = u.Username + uname = userName + + fmt.Printf("New Password:") + ab, err := termmode.ReadPassword(os.Stdin.Fd()) + fmt.Printf("\r\n") + if err != nil { + log.Fatal(err) + os.Exit(1) + } + newpw = string(ab) + + fmt.Printf("Confirm:") + ab, err = termmode.ReadPassword(os.Stdin.Fd()) + fmt.Printf("\r\n") + if err != nil { + log.Fatal(err) + os.Exit(1) + } + confirmpw = string(ab) + + if confirmpw != newpw { + log.Println("New passwords do not match.") + os.Exit(1) + } + + err = lpasswd.SetPasswd(uname, newpw, pfName) + if err != nil { + log.Fatal(err) + } +}