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"