diff --git a/go.mod b/go.mod index d1f6602..0dfc609 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require github.com/mgechev/revive v1.3.3 require ( github.com/BurntSushi/toml v1.3.2 // indirect + github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab // indirect github.com/fatih/color v1.15.0 // indirect github.com/fatih/structtag v1.2.0 // indirect @@ -16,6 +17,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pkg/errors v0.9.1 // indirect - golang.org/x/sys v0.11.0 // indirect + golang.org/x/crypto v0.13.0 + golang.org/x/sys v0.12.0 // indirect golang.org/x/tools v0.12.0 // indirect ) diff --git a/go.sum b/go.sum index 54bd5fd..5a2f2d5 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI= +github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec= github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab h1:5JxePczlyGAtj6R1MUEFZ/UFud6FfsOejq7xLC2ZIb0= github.com/chavacava/garif v0.0.0-20230608123814-4bd63c2919ab/go.mod h1:XMyYCkEL58DF0oyW4qDjjnPWONs2HBqYKI+UIPD+Gww= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -35,11 +37,13 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= -golang.org/x/sys v0.11.0/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= golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss= golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/htpasswd/htpasswd.go b/htpasswd/htpasswd.go new file mode 100644 index 0000000..e82ac33 --- /dev/null +++ b/htpasswd/htpasswd.go @@ -0,0 +1,324 @@ +// Package htpasswd contains utilities for manipulating .htpasswd files +package htpasswd + +import ( + "bytes" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + + "github.com/GehirnInc/crypt/apr1_crypt" + "golang.org/x/crypto/bcrypt" +) + +// Passwds name => hash +type Passwds map[string]string + +// HashAlgorithm enum for hashing algorithms +type HashAlgorithm string + +const ( + // HashAPR1 Apache MD5 crypt + HashAPR1 HashAlgorithm = "apr1" + // HashBCrypt bcrypt + HashBCrypt HashAlgorithm = "bcrypt" + // HashSHA SHA + HashSHA HashAlgorithm = "sha" + // HashSSHA Salted SHA + HashSSHA HashAlgorithm = "ssha" +) + +// ParseHtpasswdFile parses a .htpasswd file +// and returns a Passwd type +func ParseHtpasswdFile(file string) (Passwds, error) { + htpasswdBytes, err := os.ReadFile(file) + if err != nil { + return nil, err + } + return ParseHtpasswd(htpasswdBytes) +} + +// ParseHtpasswd parses a slice of bytes in htpasswd style +func ParseHtpasswd(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, " ") + if len(line) == 0 { + // skipping empty lines + continue + } + + parts := strings.Split(line, ":") + if ok, err := validLine(parts, lineNumber, line); !ok { + return passwords, err + } + + parts = trimParts(parts) + _, exists := passwords[parts[0]] + + if exists { + err = errors.New("invalid htpasswords file - user " + + parts[0] + " defined more than once") + return passwords, err + } + passwords[parts[0]] = parts[1] + } + return passwords, err +} + +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 + } + return true, nil +} + +func trimParts(parts []string) []string { + for i, part := range parts { + parts[i] = strings.Trim(part, " ") + } + return parts +} + +// CreateUser creates a record in the named file with +// the named password and hash algorithm +func CreateUser(file, user, passwd string, algo HashAlgorithm) error { + pp, err := ParseHtpasswdFile(file) + if err != nil { + return err + } + if _, exists := pp[user]; exists { + return fmt.Errorf("user %s already exists", user) + } + h, err := createHash(passwd, algo) + if err != nil { + return err + } + pp[user] = h + return os.WriteFile(file, pp.toByte(), os.ModePerm) +} + +// UpdateUser will update the password for the named user +// in the named file +func UpdateUser(file, user, passwd string, algo HashAlgorithm) error { + pp, err := ParseHtpasswdFile(file) + if err != nil { + return err + } + if _, exists := pp[user]; !exists { + return fmt.Errorf("user %s does not exist", user) + } + h, err := createHash(passwd, algo) + if err != nil { + return err + } + pp[user] = h + return os.WriteFile(file, pp.toByte(), os.ModePerm) +} + +// DeleteUser deletes the named user from the named file +func DeleteUser(file, user string) error { + pp, err := ParseHtpasswdFile(file) + if err != nil { + return err + } + if _, exists := pp[user]; !exists { + return fmt.Errorf("user %s does not exist", user) + } + + delete(pp, user) + return os.WriteFile(file, pp.toByte(), os.ModePerm) +} + +// 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) + if err != nil { + return err + } + return pp.Verify(user, passwd) +} + +// Verify will check if the given user and password are matching +// with the given Passwd object content +func (pp Passwds) Verify(user, passwd string) error { + if _, ok := pp[user]; !ok { + return fmt.Errorf("user %s does not exist", user) + } + alg, err := identifyHash(pp[user]) + if err != nil { + return fmt.Errorf("cannot identify algo %v", alg) + } + return verifyPass(passwd, pp[user], alg) +} + +func verifyPass(pass, hash string, alg HashAlgorithm) error { + switch alg { + case HashAPR1: + return verifyAPR1(pass, hash) + case HashBCrypt: + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(pass)) + case HashSHA: + return verifySHA(pass, hash) + case HashSSHA: + return verifySSHA(pass, hash) + } + return fmt.Errorf("unsupported hash algorithm %v", alg) +} + +func verifyAPR1(password, hashedPassword string) error { + return apr1_crypt.New().Verify(hashedPassword, []byte(password)) +} + +func verifySHA(password, hashedPassword string) error { + eppS := hashedPassword[5:] + hash, err := base64.StdEncoding.DecodeString(eppS) + + if err != nil { + return fmt.Errorf("cannot base64 decode") + } + + sha := sha1.New() + _, err = sha.Write([]byte(password)) + + if err != nil { + return err + } + + sum := sha.Sum(nil) + + if !bytes.Equal(sum, hash) { + return fmt.Errorf("wrong password") + } + return nil +} + +func verifySSHA(password, hashedPassword string) error { + eppS := hashedPassword[6:] + hash, err := base64.StdEncoding.DecodeString(eppS) + + if err != nil { + return fmt.Errorf("cannot base64 decode") + } + + salt := hash[len(hash)-4:] + sha := sha1.New() + _, err = sha.Write([]byte(password)) + + if err != nil { + return err + } + + _, err = sha.Write(salt) + + if err != nil { + return err + } + + sum := sha.Sum(nil) + + if !bytes.Equal(sum, hash[:len(hash)-4]) { + return fmt.Errorf("wrong password") + } + + return nil +} +func (pp Passwds) toByte() []byte { + pass := []byte{} + for name, hash := range pp { + pass = append(pass, []byte(name+":"+hash+"\n")...) + } + return pass +} + +func identifyHash(h string) (HashAlgorithm, error) { + switch { + case strings.HasPrefix(h, "$2a$"), strings.HasPrefix(h, "$2y$"), + strings.HasPrefix(h, "$2x$"), strings.HasPrefix(h, "$2b$"): + return HashBCrypt, nil + case strings.HasPrefix(h, "$apr1$"): + return HashAPR1, nil + case strings.HasPrefix(h, "{SHA}"): + return HashSHA, nil + case strings.HasPrefix(h, "{SSHA}"): + return HashSSHA, nil + } + + return "", fmt.Errorf("unsupported hash algorithm") +} + +func createHash(passwd string, algo HashAlgorithm) (string, error) { + hash := "" + prefix := "" + var err error + switch algo { + case HashAPR1: + hash, err = hashApr1(passwd) + case HashBCrypt: + hash, err = hashBcrypt(passwd) + case HashSHA: + prefix = "{SHA}" + hash, err = hashSha(passwd) + case HashSSHA: + prefix = "{SSHA}" + hash, err = hashSSha(passwd) + } + if err != nil { + return "", err + } + + return prefix + hash, nil +} + +func hashApr1(passwd string) (string, error) { + return apr1_crypt.New().Generate([]byte(passwd), nil) +} + +func hashBcrypt(passwd string) (string, error) { + passwordBytes, err := bcrypt.GenerateFromPassword([]byte(passwd), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(passwordBytes), nil +} + +func hashSha(passwd string) (string, error) { + s := sha1.New() + _, err := s.Write([]byte(passwd)) + if err != nil { + return "", err + } + passwordSum := []byte(s.Sum(nil)) + return base64.StdEncoding.EncodeToString(passwordSum), nil +} +func hashSSha(passwd string) (string, error) { + s := sha1.New() + _, err := s.Write([]byte(passwd)) + if err != nil { + return "", err + } + salt := make([]byte, 4) + _, err = rand.Read(salt) + if err != nil { + return "", err + } + _, err = s.Write([]byte(salt)) + if err != nil { + return "", err + } + passwordSum := []byte(s.Sum(nil)) + ret := append(passwordSum, salt...) + return base64.StdEncoding.EncodeToString(ret), nil +} diff --git a/htpasswd/htpasswd_test.go b/htpasswd/htpasswd_test.go new file mode 100644 index 0000000..1a49df2 --- /dev/null +++ b/htpasswd/htpasswd_test.go @@ -0,0 +1,90 @@ +package htpasswd + +import ( + "os" + "testing" +) + +func TestParseHtpassd(t *testing.T) { + passwords, err := ParseHtpasswd([]byte("sha:{SHA}IRRjboXT92QSYXm8lpGPCZUvU1E=\n")) + if err != nil { + t.Fatal(err) + } + if len(passwords) != 1 { + t.Fatalf("unexpected length in passwords, expected 1 got %v", len(passwords)) + } + const expected = "{SHA}IRRjboXT92QSYXm8lpGPCZUvU1E=" + if passwords["sha"] != expected { + t.Fatalf("sha password was wrong, got %s and expected %s", passwords["sha"], expected) + } +} + +func TestCreateUser(t *testing.T) { + f, err := os.Create("htpasswd_testdata") + if err != nil { + t.Fatal(err) + } + testCases := []struct { + user, password string + hash HashAlgorithm + expected error + }{ + {"apr1", "123456@", HashAPR1, nil}, + {"bcrypt", "123456@", HashBCrypt, nil}, + {"ssha", "123456@", HashSSHA, nil}, + {"sha", "123456@", HashSHA, nil}, + } + + for _, tc := range testCases { + err := CreateUser(f.Name(), tc.user, tc.password, tc.hash) + if err != tc.expected { + t.Errorf("CreateUser(%s %s, %s, %s) = %d; want %d", f.Name(), + tc.user, tc.password, tc.hash, err, tc.expected) + } + } +} +func TestVerifyPassword(t *testing.T) { + f, err := os.Stat("htpasswd_testdata") + if err != nil { + TestCreateUser(t) + } + + _, err = ParseHtpasswdFile(f.Name()) + if err != nil { + t.Fatal(err) + } + + testCases := []struct { + user, password string + expected error + }{ + {"apr1", "123456@", nil}, + {"bcrypt", "123456@", nil}, + {"ssha", "123456@", nil}, + {"sha", "123456@", nil}, + } + + for _, tc := range testCases { + err := VerifyUser(f.Name(), tc.user, tc.password) + if err != tc.expected { + t.Errorf("VerifyUser(%s %s, %s) = %v; want %v", + f, tc.user, tc.password, err, tc.expected) + } + } +} + +func TestVerify(t *testing.T) { + f, err := os.Stat("htpasswd_testdata") + if err != nil { + TestCreateUser(t) + } + + pp, err := ParseHtpasswdFile(f.Name()) + if err != nil { + t.Fatal(err) + } + err = pp.Verify("bcrypt", "123456@") + if err != nil { + t.Fatalf(err.Error()) + } +} diff --git a/htpasswd/htpasswd_testdata b/htpasswd/htpasswd_testdata new file mode 100644 index 0000000..0545eb6 --- /dev/null +++ b/htpasswd/htpasswd_testdata @@ -0,0 +1,4 @@ +bcrypt:$2a$10$9NdvqvFl9Yz9FM2D.i9Na.K1CiNF1ldk9hgRR57lYJiRBUnGt2THq +ssha:{SSHA}7fPHbfKv92vC/IFhKdnEKSTBKubvta9a +sha:{SHA}YEn9/RmoXLdbyB9TEDJ0OqWoPy8= +apr1:$apr1$U9.3kynN$MCKs53Oz35J0OYrSxfheW.