diff --git a/cmd/maddyctl/hash.go b/cmd/maddyctl/hash.go new file mode 100644 index 0000000..5863e3e --- /dev/null +++ b/cmd/maddyctl/hash.go @@ -0,0 +1,77 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + + "github.com/foxcpp/maddy/cmd/maddyctl/clitools" + "github.com/foxcpp/maddy/internal/auth/pass_table" + "github.com/urfave/cli" + "golang.org/x/crypto/bcrypt" +) + +func hashCommand(ctx *cli.Context) error { + hashFunc := ctx.String("hash") + if hashFunc == "" { + hashFunc = pass_table.DefaultHash + } + + hashCompute := pass_table.HashCompute[hashFunc] + if hashCompute == nil { + var funcs []string + for k := range pass_table.HashCompute { + funcs = append(funcs, k) + } + + return fmt.Errorf("Error: Unknown hash function, available: %s", strings.Join(funcs, ", ")) + } + + opts := pass_table.HashOpts{ + BcryptCost: bcrypt.DefaultCost, + Argon2Memory: 1024, + Argon2Time: 2, + Argon2Threads: 1, + } + if ctx.IsSet("bcrypt-cost") { + if ctx.Int("bcrypt-cost") > bcrypt.MaxCost { + return errors.New("Error: too big bcrypt cost") + } + if ctx.Int("bcrypt-cost") < bcrypt.MinCost { + return errors.New("Error: too small bcrypt cost") + } + opts.BcryptCost = ctx.Int("bcrypt-cost") + } + if ctx.IsSet("argon2-memory") { + opts.Argon2Memory = uint32(ctx.Int("argon2-memory")) + } + if ctx.IsSet("argon2-time") { + opts.Argon2Memory = uint32(ctx.Int("argon2-time")) + } + if ctx.IsSet("argon2-threads") { + opts.Argon2Threads = uint8(ctx.Int("argon2-threads")) + } + + var pass string + if ctx.IsSet("password") { + pass = ctx.String("password") + } else { + var err error + pass, err = clitools.ReadPassword("Password") + if err != nil { + return err + } + } + + if pass == "" { + fmt.Fprintln(os.Stderr, "WARNING: This is the hash of empty string") + } + + hash, err := hashCompute(opts, pass) + if err != nil { + return err + } + fmt.Println(hashFunc + ":" + hash) + return nil +} diff --git a/cmd/maddyctl/main.go b/cmd/maddyctl/main.go index bf8e9cf..3f58842 100644 --- a/cmd/maddyctl/main.go +++ b/cmd/maddyctl/main.go @@ -558,6 +558,42 @@ func main() { }, }, }, + { + Name: "hash", + Usage: "Generate password hashes for use with pass_table", + Action: hashCommand, + Flags: []cli.Flag{ + cli.StringFlag{ + Name: "password,p", + Usage: "Use `PASSWORD instead of reading password from stdin\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!", + }, + cli.StringFlag{ + Name: "hash", + Usage: "Use specified hash algorithm", + Value: "bcrypt", + }, + cli.IntFlag{ + Name: "bcrypt-cost", + Usage: "Specify bcrypt cost value", + Value: bcrypt.DefaultCost, + }, + cli.IntFlag{ + Name: "argon2-time", + Usage: "Time factor for Argon2id", + Value: 3, + }, + cli.IntFlag{ + Name: "argon2-memory", + Usage: "Memory in KiB to use for Argon2id", + Value: 1024, + }, + cli.IntFlag{ + Name: "argon2-threads", + Usage: "Threads to use for Argon2id", + Value: 1, + }, + }, + }, } if err := app.Run(os.Args); err != nil { diff --git a/docs/man/maddy-auth.5.scd b/docs/man/maddy-auth.5.scd index f30bf3a..28aff52 100644 --- a/docs/man/maddy-auth.5.scd +++ b/docs/man/maddy-auth.5.scd @@ -145,9 +145,40 @@ chown root:maddy /usr/lib/maddy/maddy-shadow-helper chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-shadow-helper ``` +# Table-based password hash lookup (pass_table) + +This module implements username:password authentication by looking up the +password hash using a table module (maddy-tables(5)). It can be used +to load user credentials from text file (file_table module) or SQL query +(sql_table module). + + +Definition: +``` +pass_table [table arguments] { + [additional table config] +} +``` + +Example, read username:password pair from the text file: +``` +smtp tcp://0.0.0.0:587 { + auth pass_table file_table /etc/maddy/smtp_passwd + ... +} +``` + +## Password hashes + +pass_table expects the used table to contain certain structured values with +hash algorithm name, salt and other necessary parameters. + +You should use 'maddyctl hash' command to generate suitable values. +See 'maddyctl hash --help' for details. + # Separate username and password lookup (plain_separate) -This modules implements authentication using username:password pairs but can +This module implements authentication using username:password pairs but can use zero or more "table modules" (maddy-tables(5)) and one or more authentication providers to verify credentials. diff --git a/go.sum b/go.sum index 28a004a..83522ea 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,7 @@ golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413 h1:ULYEB3JvPRE/IfO+9uO7vK golang.org/x/crypto v0.0.0-20191206172530-e9b2fee46413/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6 h1:Sy5bstxEqwwbYs6n0/pBuxKENqOeZUgD45Gp3Q3pqLg= golang.org/x/crypto v0.0.0-20200214034016-1d94cc7ab1c6/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d h1:1ZiEyfaQIg3Qh0EoqpwAakHVhecoE5wlSg5GjnafJGw= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= diff --git a/internal/auth/pass_table/hash.go b/internal/auth/pass_table/hash.go new file mode 100644 index 0000000..35f2b31 --- /dev/null +++ b/internal/auth/pass_table/hash.go @@ -0,0 +1,162 @@ +package pass_table + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "fmt" + "io" + "strconv" + "strings" + + "golang.org/x/crypto/argon2" + "golang.org/x/crypto/bcrypt" +) + +const ( + HashSHA256 = "sha256" + HashBcrypt = "bcrypt" + HashArgon2 = "argon2" + + DefaultHash = HashBcrypt + + Argon2Salt = 16 + Argon2Size = 64 +) + +type ( + // HashOpts is the structure that holds additional parameters for used hash + // functions. They are used for new passwords. + // + // These parameters should be stored together with the hashed password + // so it can be verified independently of the used HashOpts. + HashOpts struct { + // Bcrypt cost value to use. Should be at least 10. + BcryptCost int + + Argon2Time uint32 + Argon2Memory uint32 + Argon2Threads uint8 + } + + FuncHashCompute func(opts HashOpts, pass string) (string, error) + FuncHashVerify func(pass, hashSalt string) error +) + +var ( + HashCompute = map[string]FuncHashCompute{ + HashBcrypt: computeBcrypt, + HashArgon2: computeArgon2, + } + HashVerify = map[string]FuncHashVerify{ + HashBcrypt: verifyBcrypt, + HashArgon2: verifyArgon2, + } + + Hashes = []string{HashSHA256, HashBcrypt, HashArgon2} +) + +func computeArgon2(opts HashOpts, pass string) (string, error) { + salt := make([]byte, Argon2Salt) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("pass_table: failed to generate salt: %w", err) + } + + hash := argon2.IDKey([]byte(pass), salt, opts.Argon2Time, opts.Argon2Memory, opts.Argon2Threads, Argon2Size) + var out strings.Builder + out.WriteString(strconv.FormatUint(uint64(opts.Argon2Time), 10)) + out.WriteRune(':') + out.WriteString(strconv.FormatUint(uint64(opts.Argon2Memory), 10)) + out.WriteRune(':') + out.WriteString(strconv.FormatUint(uint64(opts.Argon2Threads), 10)) + out.WriteRune(':') + out.WriteString(base64.StdEncoding.EncodeToString(salt)) + out.WriteRune(':') + out.WriteString(base64.StdEncoding.EncodeToString(hash)) + return out.String(), nil +} + +func verifyArgon2(pass, hashSalt string) error { + parts := strings.SplitN(hashSalt, ":", 5) + + time, err := strconv.ParseUint(parts[0], 10, 32) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string: %w", err) + } + memory, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string: %w", err) + } + threads, err := strconv.ParseUint(parts[2], 10, 8) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string: %w", err) + } + salt, err := base64.StdEncoding.DecodeString(parts[3]) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string: %w", err) + } + hash, err := base64.StdEncoding.DecodeString(parts[4]) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string: %w", err) + } + + passHash := argon2.IDKey([]byte(pass), salt, uint32(time), uint32(memory), uint8(threads), Argon2Size) + if subtle.ConstantTimeCompare(passHash, hash) != 1 { + return fmt.Errorf("pass_table: hash mismatch") + } + return nil +} + +func computeSHA256(_ HashOpts, pass string) (string, error) { + salt := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return "", fmt.Errorf("pass_table: failed to generate salt: %w", err) + } + + hashInput := salt + hashInput = append(hashInput, []byte(pass)...) + sum := sha256.Sum256(hashInput) + return base64.StdEncoding.EncodeToString(salt) + ":" + base64.StdEncoding.EncodeToString(sum[:]), nil +} + +func verifySHA256(pass, hashSalt string) error { + parts := strings.Split(hashSalt, ":") + if len(parts) != 2 { + return fmt.Errorf("pass_table: malformed hash string, no salt") + } + salt, err := base64.StdEncoding.DecodeString(parts[0]) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string, cannot decode pass: %w", err) + } + hash, err := base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return fmt.Errorf("pass_table: malformed hash string, cannot decode pass: %w", err) + } + + hashInput := salt + hashInput = append(hashInput, []byte(pass)...) + sum := sha256.Sum256(hashInput) + + if subtle.ConstantTimeCompare(sum[:], hash) != 1 { + return fmt.Errorf("pass_table: hash mismatch") + } + return nil +} + +func computeBcrypt(opts HashOpts, pass string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(pass), opts.BcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func verifyBcrypt(pass, hashSalt string) error { + return bcrypt.CompareHashAndPassword([]byte(hashSalt), []byte(pass)) +} + +func addSHA256() { + HashCompute[HashSHA256] = computeSHA256 + HashVerify[HashSHA256] = verifySHA256 +} diff --git a/internal/auth/pass_table/table.go b/internal/auth/pass_table/table.go new file mode 100644 index 0000000..165050f --- /dev/null +++ b/internal/auth/pass_table/table.go @@ -0,0 +1,72 @@ +package pass_table + +import ( + "fmt" + "strings" + + "github.com/foxcpp/maddy/internal/config" + modconfig "github.com/foxcpp/maddy/internal/config/module" + "github.com/foxcpp/maddy/internal/module" + "golang.org/x/text/secure/precis" +) + +type Auth struct { + modName string + instName string + inlineArgs []string + + table module.Table +} + +func New(modName, instName string, _, inlineArgs []string) (module.Module, error) { + if len(inlineArgs) < 1 { + return nil, fmt.Errorf("%s: specify the table to use", modName) + } + + return &Auth{ + modName: modName, + instName: instName, + inlineArgs: inlineArgs, + }, nil +} + +func (a *Auth) Init(cfg *config.Map) error { + return modconfig.ModuleFromNode(a.inlineArgs, cfg.Block, cfg.Globals, &a.table) +} + +func (a *Auth) Name() string { + return a.modName +} + +func (a *Auth) InstanceName() string { + return a.instName +} + +func (a *Auth) AuthPlain(username, password string) error { + key, err := precis.UsernameCaseMapped.CompareKey(username) + if err != nil { + return err + } + + hash, ok, err := a.table.Lookup(key) + if !ok { + return module.ErrUnknownCredentials + } + if err != nil { + return err + } + + parts := strings.SplitN(hash, ":", 2) + if len(parts) != 2 { + return fmt.Errorf("%s: no hash tag", a.modName) + } + hashVerify := HashVerify[parts[0]] + if hashVerify == nil { + return fmt.Errorf("%s: unknown hash: %s", a.modName, parts[0]) + } + return hashVerify(password, parts[1]) +} + +func init() { + module.Register("pass_table", New) +} diff --git a/internal/auth/pass_table/table_test.go b/internal/auth/pass_table/table_test.go new file mode 100644 index 0000000..8a7b2d7 --- /dev/null +++ b/internal/auth/pass_table/table_test.go @@ -0,0 +1,52 @@ +package pass_table + +import ( + "testing" + + "github.com/foxcpp/maddy/internal/config" + "github.com/foxcpp/maddy/internal/testutils" +) + +func TestAuth_AuthPlain(t *testing.T) { + addSHA256() + + mod, err := New("pass_table", "", nil, nil) + if err != nil { + t.Fatal(err) + } + err = mod.Init(config.NewMap(nil, &config.Node{ + Children: []config.Node{ + { + // We replace it later with our mock. + Name: "table", + Args: []string{"dummy"}, + }, + }, + })) + if err != nil { + t.Fatal(err) + } + a := mod.(*Auth) + a.table = testutils.Table{ + M: map[string]string{ + "foxcpp": "sha256:U0FMVA==:8PDRAgaUqaLSk34WpYniXjaBgGM93Lc6iF4pw2slthw=", + "not-foxcpp": "bcrypt:$2y$10$4tEJtJ6dApmhETg8tJ4WHOeMtmYXQwmHDKIyfg09Bw1F/smhLjlaa", + "not-foxcpp-2": "argon2:1:8:1:U0FBQUFBTFQ=:KHUshl3DcpHR3AoVd28ZeBGmZ1Fj1gwJgNn98Ia8DAvGHqI0BvFOMJPxtaAfO8F+qomm2O3h0P0yV50QGwXI/Q==", + }, + } + + check := func(user, pass string, ok bool) { + t.Helper() + + err := a.AuthPlain(user, pass) + if (err == nil) != ok { + t.Errorf("ok=%v, err: %v", ok, err) + } + } + + check("foxcpp", "password", true) + check("foxcpp", "different-password", false) + check("not-foxcpp", "password", true) + check("not-foxcpp", "different-password", false) + check("not-foxcpp-2", "password", true) +} diff --git a/internal/module/dummy.go b/internal/module/dummy.go index 180bff5..f8d9c60 100644 --- a/internal/module/dummy.go +++ b/internal/module/dummy.go @@ -19,6 +19,10 @@ func (d *Dummy) AuthPlain(username, _ string) error { return nil } +func (d *Dummy) Lookup(_ string) (string, bool, error) { + return "", false, nil +} + func (d *Dummy) Name() string { return "dummy" } diff --git a/internal/testutils/table.go b/internal/testutils/table.go new file mode 100644 index 0000000..8f2b55a --- /dev/null +++ b/internal/testutils/table.go @@ -0,0 +1,11 @@ +package testutils + +type Table struct { + M map[string]string + Err error +} + +func (m Table) Lookup(a string) (string, bool, error) { + b, ok := m.M[a] + return b, ok, m.Err +} diff --git a/maddy.go b/maddy.go index bdbae73..3275be0 100644 --- a/maddy.go +++ b/maddy.go @@ -21,6 +21,8 @@ import ( // Import packages for side-effect of module registration. _ "github.com/foxcpp/maddy/internal/auth/external" _ "github.com/foxcpp/maddy/internal/auth/pam" + _ "github.com/foxcpp/maddy/internal/auth/pass_table" + _ "github.com/foxcpp/maddy/internal/auth/plain_separate" _ "github.com/foxcpp/maddy/internal/auth/shadow" _ "github.com/foxcpp/maddy/internal/check/command" _ "github.com/foxcpp/maddy/internal/check/dkim" @@ -33,6 +35,7 @@ import ( _ "github.com/foxcpp/maddy/internal/modify" _ "github.com/foxcpp/maddy/internal/modify/dkim" _ "github.com/foxcpp/maddy/internal/storage/sql" + _ "github.com/foxcpp/maddy/internal/table" _ "github.com/foxcpp/maddy/internal/target/queue" _ "github.com/foxcpp/maddy/internal/target/remote" _ "github.com/foxcpp/maddy/internal/target/smtp_downstream"