diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..319ab66 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "cSpell.words": [ + "hasher", + "htpasswd", + "Passwds" + ] +} diff --git a/go.mod b/go.mod index 0dfc609..86c4b2a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module asciigoat.org/httools -go 1.21.1 +go 1.20 require github.com/mgechev/revive v1.3.3 diff --git a/go.sum b/go.sum index 5a2f2d5..511342f 100644 --- a/go.sum +++ b/go.sum @@ -40,7 +40,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/htpasswd/errors.go b/htpasswd/errors.go new file mode 100644 index 0000000..70e78d1 --- /dev/null +++ b/htpasswd/errors.go @@ -0,0 +1,49 @@ +package htpasswd + +import ( + "errors" + "strings" +) + +var ( + // ErrExists indicates the specified user already exists + ErrExists = errors.New("user already exists") + // ErrNotExists indicates the specified user does not exist + ErrNotExists = errors.New("user does not exist") + // ErrInvalidAlgorithm indicates the htpasswd entry doesn't contain a + // valid algorithm signature + ErrInvalidAlgorithm = errors.New("invalid algorithm") + // ErrInvalidPassword indicates the offered password doesn't match + ErrInvalidPassword = errors.New("invalid password") +) + +// UserError indicates an error associated to the specified user +type UserError struct { + Name string + Hint string + Err error +} + +func (e *UserError) Error() string { + var buf strings.Builder + var reason string + + if e.Hint != "" { + reason = e.Hint + } else { + reason = e.Err.Error() + } + + if e.Name == "" { + return reason + } + + _, _ = buf.WriteString(e.Name) + _, _ = buf.WriteString(": ") + _, _ = buf.WriteString(reason) + return buf.String() +} + +func (e *UserError) Unwrap() error { + return e.Err +} diff --git a/htpasswd/htpasswd.go b/htpasswd/htpasswd.go index 8d16752..3706236 100644 --- a/htpasswd/htpasswd.go +++ b/htpasswd/htpasswd.go @@ -2,7 +2,6 @@ package htpasswd import ( - "errors" "fmt" "os" "strings" @@ -19,51 +18,53 @@ type Hasher interface { Prefix() string } -// ParseHtpasswdFile parses a .htpasswd file +// ParseFile parses a .htpasswd file // and returns a Passwd type -func ParseHtpasswdFile(file string) (Passwds, error) { +func ParseFile(file string) (Passwds, error) { htpasswdBytes, err := os.ReadFile(file) if err != nil { return nil, err } - return ParseHtpasswd(htpasswdBytes) + return Parse(htpasswdBytes) } -// ParseHtpasswd parses a slice of bytes in htpasswd style -func ParseHtpasswd(htpasswdBytes []byte) (Passwds, error) { +// Parse parses a slice of bytes in htpasswd style +func Parse(htpasswdBytes []byte) (Passwds, error) { lines := strings.Split(string(htpasswdBytes), "\n") passwords := make(map[string]string) var err error for lineNumber, line := range lines { - line = strings.Trim(line, " ") + line = strings.TrimSpace(line) if len(line) == 0 { // skipping empty lines continue } - parts := strings.Split(line, ":") - if ok, err := validLine(parts, lineNumber, line); !ok { + user, password, err := splitLine(line, lineNumber) + if err != nil { return passwords, err } - parts = trimParts(parts) - _, exists := passwords[parts[0]] - + _, exists := passwords[user] if exists { - err = errors.New("invalid htpasswords file - user " + - parts[0] + " defined more than once") + err = &UserError{ + Name: user, + Err: ErrExists, + } return passwords, err } - passwords[parts[0]] = parts[1] + + passwords[user] = password } + return passwords, err } // CreateUser creates a record in the named file with // the named password and hash algorithm func CreateUser(file, user, passwd string, algo Hasher) error { - pp, err := ParseHtpasswdFile(file) + pp, err := ParseFile(file) if err != nil { return err } @@ -71,15 +72,19 @@ func CreateUser(file, user, passwd string, algo Hasher) error { if err != nil { return err } - return pp.Write(file) + return pp.WriteFile(file) } // CreateUser will create a new user in the given Passwd object // using the given name, password and hashing algorithm func (pp Passwds) CreateUser(user, passwd string, algo Hasher) error { if _, exists := pp[user]; exists { - return fmt.Errorf("user %s already exists", user) + return &UserError{ + Name: user, + Err: ErrExists, + } } + h, err := algo.Hash(passwd) if err != nil { return err @@ -91,7 +96,7 @@ func (pp Passwds) CreateUser(user, passwd string, algo Hasher) error { // UpdateUser will update the password for the named user // in the named file func UpdateUser(file, user, passwd string, algo Hasher) error { - pp, err := ParseHtpasswdFile(file) + pp, err := ParseFile(file) if err != nil { return err } @@ -99,15 +104,19 @@ func UpdateUser(file, user, passwd string, algo Hasher) error { if err != nil { return err } - return pp.Write(file) + return pp.WriteFile(file) } // UpdateUser will update the password for the named user // using the given name, password and hashing algorithm func (pp Passwds) UpdateUser(user, passwd string, algo Hasher) error { if _, exists := pp[user]; !exists { - return fmt.Errorf("user %s does not exist", user) + return &UserError{ + Name: user, + Err: ErrNotExists, + } } + h, err := algo.Hash(passwd) if err != nil { return err @@ -118,7 +127,7 @@ func (pp Passwds) UpdateUser(user, passwd string, algo Hasher) error { // DeleteUser deletes the named user from the named file func DeleteUser(file, user string) error { - pp, err := ParseHtpasswdFile(file) + pp, err := ParseFile(file) if err != nil { return err } @@ -127,13 +136,16 @@ func DeleteUser(file, user string) error { return err } - return pp.Write(file) + return pp.WriteFile(file) } // DeleteUser deletes the named user from the named file func (pp Passwds) DeleteUser(user string) error { if _, exists := pp[user]; !exists { - return fmt.Errorf("user %s does not exist", user) + return &UserError{ + Name: user, + Err: ErrNotExists, + } } delete(pp, user) @@ -143,7 +155,7 @@ func (pp Passwds) DeleteUser(user string) error { // VerifyUser will check if the given user and password are matching // with the content of the given file func VerifyUser(file, user, passwd string) error { - pp, err := ParseHtpasswdFile(file) + pp, err := ParseFile(file) if err != nil { return err } @@ -154,17 +166,25 @@ func VerifyUser(file, user, passwd string) error { // with the given Passwd object func (pp Passwds) VerifyUser(user, passwd string) error { if _, ok := pp[user]; !ok { - return fmt.Errorf("user %s does not exist", user) + return &UserError{ + Name: user, + Err: ErrNotExists, + } } - alg, err := identifyHash(pp[user]) - if err != nil { - return fmt.Errorf("cannot identify algo %v", alg) + + alg := identifyHash(pp[user]) + if alg == nil { + return &UserError{ + Name: user, + Err: ErrInvalidAlgorithm, + } } + return alg.Match(passwd, pp[user]) } -// Write will cwrite the Passwd object to the given file -func (pp Passwds) Write(file string) error { +// WriteFile will write the Passwds object to the given file +func (pp Passwds) WriteFile(file string) error { return os.WriteFile(file, pp.Bytes(), os.ModePerm) } @@ -177,40 +197,38 @@ func (pp Passwds) Bytes() []byte { return pass } -func identifyHash(h string) (Hasher, error) { +func identifyHash(h string) Hasher { switch { case strings.HasPrefix(h, "$2a$"), strings.HasPrefix(h, "$2y$"), strings.HasPrefix(h, "$2x$"), strings.HasPrefix(h, "$2b$"): - return new(Bcrypt), nil + return new(Bcrypt) case strings.HasPrefix(h, "$apr1$"): - return new(Apr1), nil + return new(Apr1) case strings.HasPrefix(h, "{SHA}"): - return new(Sha), nil + return new(Sha) case strings.HasPrefix(h, "{SSHA}"): - return new(Ssha), nil + return new(Ssha) case strings.HasPrefix(h, "$5$"): - return new(Sha256), nil + return new(Sha256) case strings.HasPrefix(h, "$6$"): - return new(Sha512), nil + return new(Sha512) + default: + return nil } - - return nil, fmt.Errorf("unsupported hash algorithm") } -func validLine(parts []string, lineNumber int, line string) (bool, error) { - var err error - if len(parts) != 2 { - err = errors.New(fmt.Sprintln("invalid line", lineNumber+1, - "unexpected number of parts split by", ":", len(parts), - "instead of 2 in\"", line, "\"")) - return false, err +func splitLine(line string, lineNumber int) (user, password string, err error) { + user, password, ok := strings.Cut(line, ":") + if !ok { + return "", "", fmt.Errorf("invalid line %v", lineNumber+1) } - return true, nil -} -func trimParts(parts []string) []string { - for i, part := range parts { - parts[i] = strings.Trim(part, " ") + user = strings.TrimSpace(user) + password = strings.TrimSpace(password) + + if h := identifyHash(password); h != nil { + return "", "", fmt.Errorf("invalid algorithm on line %v", lineNumber+1) } - return parts + + return user, password, nil } diff --git a/htpasswd/htpasswd_test.go b/htpasswd/htpasswd_test.go index 874168a..1d19053 100644 --- a/htpasswd/htpasswd_test.go +++ b/htpasswd/htpasswd_test.go @@ -6,7 +6,7 @@ import ( ) func TestParseHtpasswd(t *testing.T) { - passwords, err := ParseHtpasswd([]byte("sha:{SHA}IRRjboXT92QSYXm8lpGPCZUvU1E=\n")) + passwords, err := Parse([]byte("sha:{SHA}IRRjboXT92QSYXm8lpGPCZUvU1E=\n")) if err != nil { t.Fatal(err) } @@ -51,7 +51,7 @@ func TestVerifyPassword(t *testing.T) { TestCreateUser(t) } - _, err = ParseHtpasswdFile(f.Name()) + _, err = ParseFile(f.Name()) if err != nil { t.Fatal(err) } @@ -83,7 +83,7 @@ func TestVerifyUser(t *testing.T) { TestCreateUser(t) } - pp, err := ParseHtpasswdFile(f.Name()) + pp, err := ParseFile(f.Name()) if err != nil { t.Fatal(err) }