commit 0c36a253336fde09ea42f5ff7d3916b0bf7dbad7 Author: Jon Lundy Date: Wed Dec 7 14:19:04 2022 -0700 initial commit diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e879cc0 --- /dev/null +++ b/go.mod @@ -0,0 +1,12 @@ +module github.com/sour-is/go-passwd + +go 1.19 + +require ( + github.com/matryer/is v1.4.0 + golang.org/x/crypto v0.3.0 +) + +require ( + golang.org/x/sys v0.2.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..62a3cba --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +github.com/elithrar/simple-scrypt v1.3.0 h1:KIlOlxdoQf9JWKl5lMAJ28SY2URB0XTRDn2TckyzAZg= +github.com/elithrar/simple-scrypt v1.3.0/go.mod h1:U2XQRI95XHY0St410VE3UjT7vuKb1qPwrl/EJwEqnZo= +github.com/matryer/is v1.4.0 h1:sosSmIWwkYITGrxZ25ULNDeKiMNzFSr4V/eqBQP0PeE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +golang.org/x/crypto v0.3.0 h1:a06MkbcxBrEFc0w0QIZWXrH/9cCX6KJyWbBOIwAn+7A= +golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/hlandau/easymetric.v1 v1.0.0 h1:ZbfbH7W3giuVDjWUoFhDOjjv20hiPr5HZ2yMV5f9IeE= +gopkg.in/hlandau/easymetric.v1 v1.0.0/go.mod h1:yh75hypuFzAxmvECh3ZKGCvFnIfapYJh2wv7ASaX2RE= +gopkg.in/hlandau/measurable.v1 v1.0.1 h1:wH5UZKCRUnRr1iD+xIZfwhtxhmr+bprRJttqA1Rklf4= +gopkg.in/hlandau/measurable.v1 v1.0.1/go.mod h1:6N+SYJGMTmetsx7wskULP+juuO+++tsHJkAgzvzsbuM= +gopkg.in/hlandau/passlib.v1 v1.0.11 h1:vKeHwGRdWBD9mm4bJ56GAAdBXpFUYvg/BYYkmphjnmA= +gopkg.in/hlandau/passlib.v1 v1.0.11/go.mod h1:wxGAv2CtQHlzWY8NJp+p045yl4WHyX7v2T6XbOcmqjM= diff --git a/passwd.go b/passwd.go new file mode 100644 index 0000000..4ce9245 --- /dev/null +++ b/passwd.go @@ -0,0 +1,102 @@ +package passwd + +import ( + "errors" + "fmt" + "strings" +) + +type Passwder interface { + Passwd(string, string) (string, error) + ApplyPasswd(*Passwd) +} + +type Passwd struct { + m map[string]Passwder + d Passwder + f Passwder +} + +func New(opts ...Passwder) *Passwd { + p := &Passwd{m: make(map[string]Passwder)} + p.Options(opts...) + + return p +} + +func (p *Passwd) Options(opts ...Passwder) { + for _, o := range opts { + o.ApplyPasswd(p) + } +} + +func (p *Passwd) Register(name string, pass Passwder) { + p.m[name] = pass + if p.d == nil { + p.SetDefault(pass) + } +} + +func (p *Passwd) SetDefault(pass Passwder) { + p.d = pass +} + +func (p *Passwd) SetFallthrough(pass Passwder) { + p.f = pass +} + +func (p *Passwd) Passwd(pass, hash string) (string, error) { + if hash == "" { + return p.d.Passwd(pass, hash) + } + name, algo := p.getAlgo(hash) + if algo == nil { + algo = p.f + } + if algo == nil { + return "", fmt.Errorf("%w: %s", ErrNoHandler, name) + } + return algo.Passwd(pass, hash) +} + +func (p *Passwd) IsPreferred(hash string) bool { + _, algo := p.getAlgo(hash) + if algo != nil && algo == p.d { + return true + } + return false +} + +func (p *Passwd) getAlgo(hash string) (string, Passwder) { + var algo string + if _, h, ok := strings.Cut(hash, "$"); ok { + algo, _, ok = strings.Cut(h, "$") + if !ok { + return "", nil + } + + if passwd, ok := p.m[algo]; ok { + return algo, passwd + } + + return algo, nil + } + + return p.getName(p.f), p.f +} + +func (p *Passwd) getName(n Passwder) string { + if n == nil { + return "" + } + for k, v := range p.m { + if v == n { + return k + } + } + return "none" +} + +var ErrNoMatch = errors.New("password does not match") +var ErrBadHash = errors.New("password hash is malformed") +var ErrNoHandler = errors.New("password handler not registered") diff --git a/passwd_test.go b/passwd_test.go new file mode 100644 index 0000000..19b5645 --- /dev/null +++ b/passwd_test.go @@ -0,0 +1,103 @@ +package passwd_test + +import ( + "crypto/subtle" + "fmt" + "strings" + "testing" + + "github.com/matryer/is" + "github.com/sour-is/go-passwd" + "github.com/sour-is/go-passwd/pkg/unix" +) + +type plainPasswd struct{} + +func (p *plainPasswd) Passwd(pass string, check string) (string, error) { + if check == "" { + return fmt.Sprint("$plain$", pass), nil + } + + if subtle.ConstantTimeCompare([]byte(pass), []byte(strings.TrimPrefix(check, "$plain$"))) == 1 { + return check, nil + } + + return check, passwd.ErrNoMatch +} + +func (p *plainPasswd) ApplyPasswd(passwd *passwd.Passwd) { + passwd.Register("plain", p) + passwd.SetFallthrough(p) +} + +func Example() { + pass := "my_pass" + hash := "my_pass" + + pwd := passwd.New( + &unix.MD5{}, // first is preferred type. + &plainPasswd{}, + ) + + _, err := pwd.Passwd(pass, hash) + if err != nil { + fmt.Println("fail: ", err) + } + + // Check if we want to update. + if !pwd.IsPreferred(hash) { + newHash, err := pwd.Passwd(pass, "") + if err != nil { + fmt.Println("fail: ", err) + } + + fmt.Println("new hash:", newHash) + } + + // Output: + // new hash: $1$81ed91e1131a3a5a50d8a68e8ef85fa0 +} + +func TestPasswdHash(t *testing.T) { + type testCase struct { + pass, hash string + } + + tests := []testCase{ + {"passwd", "passwd"}, + {"passwd", "$plain$passwd"}, + } + algos := []passwd.Passwder{&plainPasswd{}} + + is := is.New(t) + // Generate additional test cases for each algo. + for _, algo := range algos { + hash, err := algo.Passwd("passwd", "") + is.NoErr(err) + tests = append(tests, testCase{"passwd", hash}) + } + + pass := passwd.New(algos...) + + for i, tt := range tests { + t.Run(fmt.Sprint("Test-", i), func(t *testing.T) { + is := is.New(t) + + hash, err := pass.Passwd(tt.pass, tt.hash) + is.Equal(hash, tt.hash) + is.NoErr(err) + }) + } +} + +func TestPasswdIsPreferred(t *testing.T) { + is := is.New(t) + + pass := passwd.New(&plainPasswd{}) + + ok := pass.IsPreferred("$plain$passwd") + is.True(ok) + + ok = pass.IsPreferred("$foo$passwd") + is.True(!ok) +} diff --git a/pkg/argon2/argon2.go b/pkg/argon2/argon2.go new file mode 100644 index 0000000..628f5b0 --- /dev/null +++ b/pkg/argon2/argon2.go @@ -0,0 +1,187 @@ +package argon2 + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" + + "github.com/sour-is/go-passwd" +) + +type argon struct { + version uint8 + time uint32 + memory uint32 + threads uint8 + keyLen uint32 + saltLen uint32 + + name string + keyFn func(password, salt []byte, time, memory uint32, threads uint8, keyLen uint32) []byte +} + +var All = []passwd.Passwder{Argon2i, Argon2id} + +// Argon2i sets default recommended values. +var Argon2i = NewArgon2i(3, 32*1024, 4, 32, 16) + +// Argon2id sets default recommended values. +var Argon2id = NewArgon2id(1, 64*1024, 4, 32, 16) + +// NewArgon2i creates a new +func NewArgon2i( + time uint32, + memory uint32, + threads uint8, + keyLen uint32, + saltLen uint32, +) *argon { + return &argon{ + version: argon2.Version, + time: time, + memory: memory, + threads: threads, + keyLen: keyLen, + saltLen: saltLen, + name: "argon2i", + keyFn: argon2.Key, + } +} + +// NewArgon2i creates a new +func NewArgon2id( + time uint32, + memory uint32, + threads uint8, + keyLen uint32, + saltLen uint32, +) *argon { + return &argon{ + version: argon2.Version, + time: time, + memory: memory, + threads: threads, + keyLen: keyLen, + saltLen: saltLen, + name: "argon2id", + keyFn: argon2.IDKey, + } +} + +func (p *argon) Passwd(pass string, check string) (string, error) { + var args *pwArgs + var err error + + if check == "" { + args = p.defaultArgs() + _, err := rand.Read(args.salt) + if err != nil { + return "", err + } + args.hash = p.keyFn([]byte(pass), args.salt, args.time, args.memory, args.threads, args.keyLen) + } else { + args, err = p.parseArgs(check) + if err != nil { + return "", err + } + hash := p.keyFn([]byte(pass), args.salt, args.time, args.memory, args.threads, args.keyLen) + + if subtle.ConstantTimeCompare(hash, args.hash) == 0 { + return "", passwd.ErrNoMatch + } + } + + return args.String(), nil +} +func (p *argon) ApplyPasswd(passwd *passwd.Passwd) { + passwd.Register(p.name, p) +} +func (p *argon) defaultArgs() *pwArgs { + return &pwArgs{ + name: p.name, + version: p.version, + time: p.time, + memory: p.memory, + threads: p.threads, + keyLen: p.keyLen, + salt: make([]byte, p.saltLen), + } +} +func (p *argon) parseArgs(hash string) (*pwArgs, error) { + pfx := "$" + p.name + "$" + + if !strings.HasPrefix(hash, pfx) { + return nil, fmt.Errorf("%w: missing prefix", passwd.ErrBadHash) + } + hash = strings.TrimPrefix(hash, pfx) + args, hash, ok := strings.Cut(hash, "$") + if !ok { + return nil, fmt.Errorf("%w: missing args", passwd.ErrBadHash) + } + salt, hash, ok := strings.Cut(hash, "$") + if !ok { + return nil, fmt.Errorf("%w: missing salt", passwd.ErrBadHash) + } + + var err error + pass := p.defaultArgs() + pass.salt, err = base64.RawStdEncoding.DecodeString(salt) + if err != nil { + return nil, fmt.Errorf("%w: corrupt salt part", passwd.ErrBadHash) + } + pass.hash, err = base64.RawStdEncoding.DecodeString(hash) + if err != nil { + return nil, fmt.Errorf("%w: corrupt hash part", passwd.ErrBadHash) + } + + pass.name = p.name + pass.keyLen = uint32(len(pass.hash)) + + for _, part := range strings.Split(args, ",") { + if k, v, ok := strings.Cut(part, "="); ok { + switch k { + case "v": + if i, err := strconv.ParseUint(v, 10, 8); err == nil { + pass.version = uint8(i) + } + case "m": + if i, err := strconv.ParseUint(v, 10, 32); err == nil { + pass.memory = uint32(i) + } + case "t": + if i, err := strconv.ParseUint(v, 10, 32); err == nil { + pass.time = uint32(i) + } + case "p": + if i, err := strconv.ParseUint(v, 10, 8); err == nil { + pass.threads = uint8(i) + } + } + } + } + + return pass, nil +} + +type pwArgs struct { + name string + version uint8 + time uint32 + memory uint32 + threads uint8 + keyLen uint32 + salt []byte + hash []byte +} + +func (p *pwArgs) String() string { + salt := base64.RawStdEncoding.EncodeToString(p.salt) + hash := base64.RawStdEncoding.EncodeToString(p.hash) + + return fmt.Sprintf("$%s$v=%d,m=%d,t=%d,p=%d$%s$%s", p.name, p.version, p.memory, p.time, p.threads, salt, hash) +} diff --git a/pkg/argon2/argon2_test.go b/pkg/argon2/argon2_test.go new file mode 100644 index 0000000..f4b7c91 --- /dev/null +++ b/pkg/argon2/argon2_test.go @@ -0,0 +1,40 @@ +package argon2_test + +import ( + "fmt" + "testing" + + "github.com/matryer/is" + + "github.com/sour-is/go-passwd" + "github.com/sour-is/go-passwd/pkg/argon2" +) + +func TestPasswdHash(t *testing.T) { + type testCase struct { + pass, hash string + } + + tests := []testCase{} + algos := argon2.All + + is := is.New(t) + // Generate additional test cases for each algo. + for _, algo := range algos { + hash, err := algo.Passwd("passwd", "") + is.NoErr(err) + tests = append(tests, testCase{"passwd", hash}) + } + + pass := passwd.New(algos...) + + for i, tt := range tests { + t.Run(fmt.Sprint("Test-", i), func(t *testing.T) { + is := is.New(t) + + hash, err := pass.Passwd(tt.pass, tt.hash) + is.NoErr(err) + is.Equal(hash, tt.hash) + }) + } +} diff --git a/pkg/scrypt/scrypt.go b/pkg/scrypt/scrypt.go new file mode 100644 index 0000000..d07401f --- /dev/null +++ b/pkg/scrypt/scrypt.go @@ -0,0 +1,174 @@ +package scrypt + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "fmt" + "strconv" + "strings" + + "github.com/sour-is/go-passwd" + "golang.org/x/crypto/scrypt" +) + +type scryptpw struct { + N int // CPU/memory cost parameter (logN) + R int // block size parameter (octets) + P int // parallelisation parameter (positive int) + SaltLen int // bytes to use as salt (octets) + DKLen int // length of the derived key (octets) + + name string + encoder interface { + EncodeToString(src []byte) string + DecodeString(s string) ([]byte, error) + } +} +type scryptArgs struct { + N int // CPU/memory cost parameter (logN) + R int // block size parameter (octets) + P int // parallelisation parameter (positive int) + SaltLen int // bytes to use as salt (octets) + DKLen int // length of the derived key (octets) + + name string + salt []byte + hash []byte + + encoder interface { + EncodeToString(src []byte) string + DecodeString(s string) ([]byte, error) + } +} + +var All = []passwd.Passwder{Simple, Scrypt2} + +var Simple = &scryptpw{ + N: 16384, R: 8, P: 1, SaltLen: 16, DKLen: 32, + name: "s1", encoder: hexenc{}, +} + +var Scrypt2 = &scryptpw{ + N: 16384, R: 8, P: 1, SaltLen: 16, DKLen: 32, + name: "s2", encoder: base64.RawStdEncoding, +} + +func (s *scryptpw) Passwd(pass string, check string) (string, error) { + var args *scryptArgs + var err error + + if check == "" { + args = s.defaultArgs() + _, err := rand.Read(args.salt) + if err != nil { + return "", err + } + args.hash, err = scrypt.Key([]byte(pass), args.salt, args.N, args.R, args.P, args.DKLen) + if err != nil { + return "", err + } + + } else { + args, err = s.parseArgs(check) + if err != nil { + return "", err + } + hash, err := scrypt.Key([]byte(pass), args.salt, args.N, args.R, args.P, args.DKLen) + if err != nil { + return "", err + } + + if subtle.ConstantTimeCompare(hash, args.hash) == 0 { + return "", passwd.ErrNoMatch + } + } + + return args.String(), nil +} +func (s *scryptpw) ApplyPasswd(p *passwd.Passwd) { + p.Register(s.name, s) + if s.name == "s1" { + p.SetFallthrough(s) + } +} +func (s *scryptpw) defaultArgs() *scryptArgs { + return &scryptArgs{ + name: s.name, + N: s.N, + R: s.R, + P: s.P, + DKLen: s.DKLen, + SaltLen: s.SaltLen, + salt: make([]byte, s.SaltLen), + encoder: s.encoder, + } +} +func (s *scryptpw) parseArgs(hash string) (*scryptArgs, error) { + args := s.defaultArgs() + + name := "$" + s.name + "$" + hash = strings.TrimPrefix(hash, name) + + N, hash, ok := strings.Cut(hash, "$") + if !ok { + return nil, fmt.Errorf("%w: missing args: N", passwd.ErrBadHash) + } + if n, err := strconv.Atoi(N); err == nil { + args.N = n + } + + R, hash, ok := strings.Cut(hash, "$") + if !ok { + return nil, fmt.Errorf("%w: missing args: R", passwd.ErrBadHash) + } + if r, err := strconv.Atoi(R); err == nil { + args.R = r + } + + P, hash, ok := strings.Cut(hash, "$") + if !ok { + return nil, fmt.Errorf("%w: missing args: P", passwd.ErrBadHash) + } + if p, err := strconv.Atoi(P); err == nil { + args.P = p + } + + salt, hash, ok := strings.Cut(hash, "$") + if !ok { + return nil, fmt.Errorf("%w: missing args: salt", passwd.ErrBadHash) + } + + var err error + args.salt, err = s.encoder.DecodeString(salt) + if err != nil { + return nil, fmt.Errorf("%w: corrupt salt part", passwd.ErrBadHash) + } + + args.hash, err = s.encoder.DecodeString(hash) + if err != nil { + return nil, fmt.Errorf("%w: corrupt hash part", passwd.ErrBadHash) + } + + return args, nil +} +func (s *scryptArgs) String() string { + var name string + if s.name != "s1" { + name = "$" + s.name + "$" + } + salt := s.encoder.EncodeToString(s.salt) + hash := s.encoder.EncodeToString(s.hash) + + return fmt.Sprintf("%s%d$%d$%d$%s$%s", name, s.N, s.R, s.P, salt, hash) +} + +type hexenc struct{} + +func (hexenc) EncodeToString(src []byte) string { + return hex.EncodeToString(src) +} +func (hexenc) DecodeString(s string) ([]byte, error) { + return hex.DecodeString(s) +} diff --git a/pkg/scrypt/scrypt_test.go b/pkg/scrypt/scrypt_test.go new file mode 100644 index 0000000..fbbbb83 --- /dev/null +++ b/pkg/scrypt/scrypt_test.go @@ -0,0 +1,40 @@ +package scrypt_test + +import ( + "fmt" + "testing" + + "github.com/matryer/is" + + "github.com/sour-is/go-passwd" + "github.com/sour-is/go-passwd/pkg/scrypt" +) + +func TestPasswdHash(t *testing.T) { + type testCase struct { + pass, hash string + } + + tests := []testCase{} + algos := scrypt.All + + is := is.New(t) + // Generate additional test cases for each algo. + for _, algo := range algos { + hash, err := algo.Passwd("passwd", "") + is.NoErr(err) + tests = append(tests, testCase{"passwd", hash}) + } + + pass := passwd.New(algos...) + + for i, tt := range tests { + t.Run(fmt.Sprint("Test-", i), func(t *testing.T) { + is := is.New(t) + + hash, err := pass.Passwd(tt.pass, tt.hash) + is.NoErr(err) + is.Equal(hash, tt.hash) + }) + } +} diff --git a/pkg/unix/unix.go b/pkg/unix/unix.go new file mode 100644 index 0000000..7f7ddee --- /dev/null +++ b/pkg/unix/unix.go @@ -0,0 +1,94 @@ +package unix + +import ( + "crypto/md5" + "crypto/subtle" + "fmt" + + "github.com/sour-is/go-passwd" + "golang.org/x/crypto/bcrypt" +) + +var All = []passwd.Passwder{ + &Blowfish{}, + &MD5{}, +} + +type MD5 struct{} + +func (p *MD5) Passwd(pass string, check string) (string, error) { + h := md5.New() + fmt.Fprint(h, pass) + + hash := fmt.Sprintf("$1$%x", h.Sum(nil)) + + return hashCheck(hash, check) +} + +func (p *MD5) ApplyPasswd(passwd *passwd.Passwd) { + passwd.Register("1", p) +} + +type Blowfish struct{} + +func (p *Blowfish) Passwd(pass string, check string) (string, error) { + if check == "" { + b, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(b), nil + } + + err := bcrypt.CompareHashAndPassword([]byte(check), []byte(pass)) + if err != nil { + return "", err + } + return check, nil +} + +func (p *Blowfish) ApplyPasswd(passwd *passwd.Passwd) { + passwd.Register("2a", p) +} + +// type SHA256 struct{} + +// func (p *SHA256) Passwd(pass string, check string) (string, error) { +// h := sha256.New() +// fmt.Fprint(h, pass) + +// hash := fmt.Sprintf("$5$%x", h.Sum(nil)) + +// return hashCheck(hash, check) +// } + +// func (p *SHA256) ApplyPasswd(passwd *passwd.Passwd) { +// passwd.Register("5", p) +// } + +// type SHA512 struct{} + +// func (p *SHA512) Passwd(pass string, check string) (string, error) { +// h := sha512.New() +// fmt.Fprint(h, pass) + +// hash := fmt.Sprintf("$6$%x", h.Sum(nil)) + +// return hashCheck(hash, check) +// } + +// func (p *SHA512) ApplyPasswd(passwd *passwd.Passwd) { +// passwd.Register("6", p) +// } + +func hashCheck(hash, check string) (string, error) { + if check == "" { + return hash, nil + } + + if subtle.ConstantTimeCompare([]byte(hash), []byte(check)) == 1 { + return hash, nil + } + + return hash, passwd.ErrNoMatch +} diff --git a/pkg/unix/unix_test.go b/pkg/unix/unix_test.go new file mode 100644 index 0000000..0dacec0 --- /dev/null +++ b/pkg/unix/unix_test.go @@ -0,0 +1,42 @@ +package unix_test + +import ( + "fmt" + "testing" + + "github.com/matryer/is" + + "github.com/sour-is/go-passwd" + "github.com/sour-is/go-passwd/pkg/unix" +) + +func TestPasswdHash(t *testing.T) { + type testCase struct { + pass, hash string + } + + tests := []testCase{ + {"passwd", "$1$76a2173be6393254e72ffa4d6df1030a"}, + {"passwd", "$2a$10$GkJwB.nOaaeAvRGgyl2TI.kruM8e.iIo.OozgdslegpNlC/vIFKRq"}, + } + + is := is.New(t) + // Generate additional test cases for each algo. + for _, algo := range unix.All { + hash, err := algo.Passwd("passwd", "") + is.NoErr(err) + tests = append(tests, testCase{"passwd", hash}) + } + + pass := passwd.New(unix.All...) + + for i, tt := range tests { + t.Run(fmt.Sprint("Test-", i), func(t *testing.T) { + is := is.New(t) + + hash, err := pass.Passwd(tt.pass, tt.hash) + is.Equal(hash, tt.hash) + is.NoErr(err) + }) + } +}