diff --git a/internal/auth/password/account.go b/internal/auth/password/account.go index e40bf105c..9f6bb2285 100644 --- a/internal/auth/password/account.go +++ b/internal/auth/password/account.go @@ -14,9 +14,9 @@ type Account struct { *store.Account tableName string - // CredentialID is included when Authenticate or ChangePassword is - // called. A new CredentialID is generated when a password is changed. - CredentialID string `gorm:"-"` + // CredentialId is included when Authenticate or ChangePassword is + // called. A new CredentialId is generated when a password is changed. + CredentialId string `gorm:"-"` } func allocAccount() Account { diff --git a/internal/auth/password/argon2.go b/internal/auth/password/argon2.go index 79ad57c2a..c53a6d4be 100644 --- a/internal/auth/password/argon2.go +++ b/internal/auth/password/argon2.go @@ -1,10 +1,17 @@ package password import ( + "context" + "crypto/rand" + "fmt" "strings" + wrapping "github.com/hashicorp/go-kms-wrapping" + "github.com/hashicorp/go-kms-wrapping/structwrapping" "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" "github.com/hashicorp/watchtower/internal/oplog" + "golang.org/x/crypto/argon2" "google.golang.org/protobuf/proto" ) @@ -100,3 +107,90 @@ func (c *Argon2Configuration) whereDup() (string, []interface{}) { return strings.Join(where, " and "), args } + +// A Argon2Credential contains a key derived from a password and the salt +// used in the key derivation. It is owned by an Account. +type Argon2Credential struct { + *store.Argon2Credential + tableName string +} + +func newArgon2Credential(accountId string, password string, conf *Argon2Configuration) (*Argon2Credential, error) { + if accountId == "" { + return nil, fmt.Errorf("new: password argon2 credential: no accountId: %w", db.ErrInvalidParameter) + } + if password == "" { + return nil, fmt.Errorf("new: password argon2 credential: no password: %w", db.ErrInvalidParameter) + } + if conf == nil { + return nil, fmt.Errorf("new: password argon2 credential: no argon2 configuration: %w", db.ErrNilParameter) + } + + id, err := newArgon2CredentialId() + if err != nil { + return nil, fmt.Errorf("new: password argon2 credential: %w", err) + } + + c := &Argon2Credential{ + Argon2Credential: &store.Argon2Credential{ + PrivateId: id, + PasswordAccountId: accountId, + PasswordConfId: conf.PrivateId, + }, + } + + salt := make([]byte, conf.SaltLength) + if _, err := rand.Read(salt); err != nil { + return nil, fmt.Errorf("new: password argon2 credential: %w", err) + } + c.Salt = salt + c.DerivedKey = argon2.IDKey([]byte(password), c.Salt, conf.Iterations, conf.Memory, uint8(conf.Threads), conf.KeyLength) + return c, nil +} + +func (c *Argon2Credential) clone() *Argon2Credential { + cp := proto.Clone(c.Argon2Credential) + return &Argon2Credential{ + Argon2Credential: cp.(*store.Argon2Credential), + } +} + +// TableName returns the table name. +func (c *Argon2Credential) TableName() string { + if c != nil && c.tableName != "" { + return c.tableName + } + return "auth_password_argon2_cred" +} + +// SetTableName sets the table name. +func (c *Argon2Credential) SetTableName(n string) { + c.tableName = n +} + +func (c *Argon2Credential) encrypt(ctx context.Context, cipher wrapping.Wrapper) error { + if err := structwrapping.WrapStruct(ctx, cipher, c.Argon2Credential, nil); err != nil { + return fmt.Errorf("error encrypting argon2 credential: %w", err) + } + return nil +} + +func (c *Argon2Credential) decrypt(ctx context.Context, cipher wrapping.Wrapper) error { + if err := structwrapping.UnwrapStruct(ctx, cipher, c.Argon2Credential, nil); err != nil { + return fmt.Errorf("error decrypting argon2 credential: %w", err) + } + return nil +} + +func (c *Argon2Credential) oplog(op oplog.OpType) oplog.Metadata { + metadata := oplog.Metadata{ + "resource-public-id": []string{c.PrivateId}, + "resource-type": []string{"argon2 credential"}, + "op-type": []string{op.String()}, + "password-account-id": []string{c.PasswordAccountId}, + } + if c.PasswordMethodId != "" { + metadata["password-method-id"] = []string{c.PasswordMethodId} + } + return metadata +} diff --git a/internal/auth/password/argon2_test.go b/internal/auth/password/argon2_test.go index 2b7589711..5060d058e 100644 --- a/internal/auth/password/argon2_test.go +++ b/internal/auth/password/argon2_test.go @@ -7,6 +7,7 @@ import ( "github.com/hashicorp/watchtower/internal/auth/password/store" "github.com/hashicorp/watchtower/internal/db" + "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -268,3 +269,157 @@ func TestArgon2Configuration_Validate(t *testing.T) { }) } } + +func testArgon2Confs(t *testing.T, conn *gorm.DB, authMethodId string, count int) []*Argon2Configuration { + t.Helper() + assert, require := assert.New(t), require.New(t) + rw := db.New(conn) + var confs []*Argon2Configuration + err := rw.SearchWhere(context.Background(), &confs, "password_method_id = ?", []interface{}{authMethodId}) + require.NoError(err) + assert.Equal(1, len(confs)) + base := confs[0] + for i := 0; i < count; i++ { + conf := NewArgon2Configuration() + require.NotNil(conf) + conf.PasswordMethodId = authMethodId + conf.PrivateId, err = newArgon2ConfigurationId() + require.NoError(err) + + conf.Iterations = base.Iterations + uint32(i+1) + conf.Threads = base.Threads + uint32(i+1) + err = rw.Create(context.Background(), conf) + require.NoError(err) + confs = append(confs, conf) + } + return confs +} + +func TestArgon2Credential_New(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + auts := testAuthMethods(t, conn, 1) + aut := auts[0] + accts := testAccounts(t, conn, aut.ScopeId, aut.PublicId, 5) + confs := testArgon2Confs(t, conn, accts[0].AuthMethodId, 1) + + type args struct { + accountId string + password string + conf *Argon2Configuration + } + var tests = []struct { + name string + args args + want *Argon2Credential + wantIsErr error + }{ + { + name: "blank-accountId", + args: args{ + accountId: "", + password: "foobarcity", + conf: confs[0], + }, + want: nil, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "blank-password", + args: args{ + accountId: accts[0].PublicId, + password: "", + conf: confs[0], + }, + want: nil, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "nil-configuration", + args: args{ + accountId: accts[0].PublicId, + password: "foobarcity", + conf: nil, + }, + want: nil, + wantIsErr: db.ErrNilParameter, + }, + { + name: "valid-password", + args: args{ + accountId: accts[0].PublicId, + password: "foobarcity", + conf: confs[0], + }, + want: &Argon2Credential{ + Argon2Credential: &store.Argon2Credential{ + PasswordAccountId: accts[0].PublicId, + PasswordConfId: confs[0].PrivateId, + }, + }, + }, + { + name: "traditional-chinese-characters-password", + args: args{ + accountId: accts[1].PublicId, + password: "漢字𫝆𫝑𫝜𫝓𫝶𫝼𫞉𫞔𫞩𫞬", + conf: confs[0], + }, + want: &Argon2Credential{ + Argon2Credential: &store.Argon2Credential{ + PasswordAccountId: accts[1].PublicId, + PasswordConfId: confs[0].PrivateId, + }, + }, + }, + { + name: "emoji-password", + args: args{ + accountId: accts[2].PublicId, + password: "😃 😀 😅 😘🤔 😑🤢🤠😰", + conf: confs[0], + }, + want: &Argon2Credential{ + Argon2Credential: &store.Argon2Credential{ + PasswordAccountId: accts[2].PublicId, + PasswordConfId: confs[0].PrivateId, + }, + }, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + got, err := newArgon2Credential(tt.args.accountId, tt.args.password, tt.args.conf) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(got) + return + } + require.NoError(err) + require.NotNil(got) + + got.encrypt(context.Background(), wrapper) + err = rw.Create(context.Background(), got) + assert.NoError(err) + + if tt.want == nil { + return + } + + gotCred := &Argon2Credential{ + Argon2Credential: &store.Argon2Credential{ + PrivateId: got.PrivateId, + }, + } + err = rw.LookupById(context.Background(), gotCred) + require.NoError(err) + assert.Equal(tt.want.PasswordAccountId, gotCred.PasswordAccountId) + assert.Equal(tt.want.PasswordConfId, gotCred.PasswordConfId) + }) + } +} diff --git a/internal/auth/password/errors.go b/internal/auth/password/errors.go index 67494c40f..00b0dbfb0 100644 --- a/internal/auth/password/errors.go +++ b/internal/auth/password/errors.go @@ -3,6 +3,10 @@ package password import "errors" var ( + // ErrTooShort results from attempting to set a password which is to + // short. + ErrTooShort = errors.New("password to short") + // ErrUnsupportedConfiguration results from attempting to perform an // operation that sets a password configuration to an unsupported type. ErrUnsupportedConfiguration = errors.New("unsupported configuration") diff --git a/internal/auth/password/options.go b/internal/auth/password/options.go index 0ebb777dd..dcf2519be 100644 --- a/internal/auth/password/options.go +++ b/internal/auth/password/options.go @@ -18,6 +18,8 @@ type options struct { withDescription string withLimit int withConfig Configuration + password string + withPassword bool } func getDefaultOptions() options { @@ -49,6 +51,14 @@ func WithLimit(l int) Option { } } +// WithPassword provides an optional password. +func WithPassword(password string) Option { + return func(o *options) { + o.password = password + o.withPassword = true + } +} + // WithConfiguration provides an optional configuration. func WithConfiguration(config Configuration) Option { return func(o *options) { diff --git a/internal/auth/password/options_test.go b/internal/auth/password/options_test.go index 4d9ccabc2..3609c5f2d 100644 --- a/internal/auth/password/options_test.go +++ b/internal/auth/password/options_test.go @@ -27,6 +27,13 @@ func Test_GetOpts(t *testing.T) { testOpts.withLimit = 5 assert.Equal(t, opts, testOpts) }) + t.Run("WithPassword", func(t *testing.T) { + opts := getOpts(WithPassword("test password")) + testOpts := getDefaultOptions() + testOpts.password = "test password" + testOpts.withPassword = true + assert.Equal(t, opts, testOpts) + }) t.Run("WithConfiguration", func(t *testing.T) { conf := NewArgon2Configuration() conf.KeyLength = conf.KeyLength * 2 diff --git a/internal/auth/password/private_ids.go b/internal/auth/password/private_ids.go index 9696cda38..b76a6eb0e 100644 --- a/internal/auth/password/private_ids.go +++ b/internal/auth/password/private_ids.go @@ -9,6 +9,7 @@ import ( // Prefixes for private ids for types in the password package. const ( argon2ConfigurationPrefix = "arg2conf" + argon2CredentialPrefix = "arg2cred" ) func newArgon2ConfigurationId() (string, error) { @@ -18,3 +19,11 @@ func newArgon2ConfigurationId() (string, error) { } return id, err } + +func newArgon2CredentialId() (string, error) { + id, err := db.NewPrivateId(argon2CredentialPrefix) + if err != nil { + return "", fmt.Errorf("new password argon2 configuration id: %w", err) + } + return id, err +} diff --git a/internal/auth/password/private_ids_test.go b/internal/auth/password/private_ids_test.go index 24d6472df..47f00c0b9 100644 --- a/internal/auth/password/private_ids_test.go +++ b/internal/auth/password/private_ids_test.go @@ -14,4 +14,9 @@ func Test_PrivateIds(t *testing.T) { require.NoError(t, err) assert.True(t, strings.HasPrefix(id, argon2ConfigurationPrefix+"_")) }) + t.Run("argon2Cred", func(t *testing.T) { + id, err := newArgon2CredentialId() + require.NoError(t, err) + assert.True(t, strings.HasPrefix(id, argon2CredentialPrefix+"_")) + }) } diff --git a/internal/auth/password/query.go b/internal/auth/password/query.go new file mode 100644 index 000000000..20d40f472 --- /dev/null +++ b/internal/auth/password/query.go @@ -0,0 +1,29 @@ +package password + +const ( + authenticateQuery = ` +select acct.name, -- Account.Name + acct.description, -- Account.Description + acct.user_name, -- Account.UserName + acct.public_id, -- Account.PublicId + acct.auth_method_id, -- Account.AuthMethodId + acct.scope_id, -- Account.ScopeId + acct.create_time, -- Account.CreateTime + acct.update_time, -- Account.UpdateTime + cred.private_id as credential_id, -- Account.CredentialId + cred.password_conf_id, -- Argon2Credential.PasswordConfId + cred.salt, -- Argon2Credential.CtSalt/Salt + cred.derived_key, -- Argon2Credential.DerivedKey + conf.key_length, -- Argon2Configuration.KeyLength + conf.iterations, -- Argon2Configuration.Iterations + conf.memory, -- Argon2Configuration.Memory + conf.threads -- Argon2Configuration.Threads + from auth_password_argon2_cred cred, + auth_password_argon2_conf conf, + auth_password_account acct + where acct.auth_method_id = $1 + and acct.user_name = $2 + and cred.password_conf_id = conf.private_id + and cred.password_account_id = acct.public_id; +` +) diff --git a/internal/auth/password/repository_account.go b/internal/auth/password/repository_account.go index e3430c8b3..9b5c3c235 100644 --- a/internal/auth/password/repository_account.go +++ b/internal/auth/password/repository_account.go @@ -15,6 +15,8 @@ import ( // contain a valid AuthMethodId. a must not contain a PublicId. The PublicId is // generated and assigned by this method. // +// WithPassword is the only valid option. All other options are ignored. +// // Both a.Name and a.Description are optional. If a.Name is set, it must be // unique within a.AuthMethodId. func (r *Repository) CreateAccount(ctx context.Context, a *Account, opt ...Option) (*Account, error) { @@ -34,6 +36,15 @@ func (r *Repository) CreateAccount(ctx context.Context, a *Account, opt ...Optio return nil, fmt.Errorf("create: password account: invalid user name: %w", db.ErrInvalidParameter) } + cc, err := r.currentConfig(ctx, a.AuthMethodId) + if err != nil { + return nil, fmt.Errorf("create: password account: retrieve current configuration: %w", err) + } + + if cc.MinUserNameLength > len(a.UserName) { + return nil, fmt.Errorf("create: password account: user name %q: %w", a.UserName, ErrTooShort) + } + a = a.clone() id, err := newAccountId() if err != nil { @@ -41,6 +52,19 @@ func (r *Repository) CreateAccount(ctx context.Context, a *Account, opt ...Optio } a.PublicId = id + opts := getOpts(opt...) + + var cred *Argon2Credential + if opts.withPassword { + if cc.MinPasswordLength > len(opts.password) { + return nil, fmt.Errorf("create: password account: password: %w", ErrTooShort) + } + if cred, err = newArgon2Credential(id, opts.password, cc.argon2()); err != nil { + return nil, fmt.Errorf("create: password account: %w", err) + } + } + + var newCred *Argon2Credential var newAccount *Account _, err = r.writer.DoTx(ctx, db.StdRetryCnt, db.ExpBackoff{}, func(_ db.Reader, w db.Writer) error { @@ -49,6 +73,15 @@ func (r *Repository) CreateAccount(ctx context.Context, a *Account, opt ...Optio return err } + if cred != nil { + newCred = cred.clone() + if err := newCred.encrypt(ctx, r.wrapper); err != nil { + return err + } + if err := w.Create(ctx, newCred, db.WithOplog(r.wrapper, cred.oplog(oplog.OpType_OP_TYPE_CREATE))); err != nil { + return err + } + } return nil }, ) diff --git a/internal/auth/password/repository_account_test.go b/internal/auth/password/repository_account_test.go index 527445e39..dfa3a4336 100644 --- a/internal/auth/password/repository_account_test.go +++ b/internal/auth/password/repository_account_test.go @@ -5,9 +5,11 @@ import ( "errors" "fmt" "testing" + "time" "github.com/hashicorp/watchtower/internal/auth/password/store" "github.com/hashicorp/watchtower/internal/db" + "github.com/hashicorp/watchtower/internal/oplog" "github.com/jinzhu/gorm" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -143,6 +145,29 @@ func TestRepository_CreateAccount(t *testing.T) { }, wantIsErr: db.ErrInvalidParameter, }, + { + name: "invalid-username-to-short", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kaz", + }, + }, + wantIsErr: ErrTooShort, + }, + { + name: "invalid-password-to-short", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak123", + }, + }, + opts: []Option{ + WithPassword("a"), + }, + wantIsErr: ErrTooShort, + }, { name: "valid-no-options", in: &Account{ @@ -192,6 +217,24 @@ func TestRepository_CreateAccount(t *testing.T) { }, }, }, + { + name: "valid-with-password", + in: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak3", + }, + }, + opts: []Option{ + WithPassword("1234567890"), + }, + want: &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak3", + }, + }, + }, } for _, tt := range tests { @@ -215,6 +258,15 @@ func TestRepository_CreateAccount(t *testing.T) { assert.Equal(tt.want.Name, got.Name) assert.Equal(tt.want.Description, got.Description) assert.Equal(got.CreateTime, got.UpdateTime) + + assert.NoError(db.TestVerifyOplog(t, rw, got.PublicId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second))) + + opts := getOpts(tt.opts...) + if opts.withPassword { + authAcct, err := repo.Authenticate(context.Background(), tt.in.AuthMethodId, tt.in.UserName, opts.password) + require.NoError(err) + assert.NoError(db.TestVerifyOplog(t, rw, authAcct.CredentialId, db.WithOperation(oplog.OpType_OP_TYPE_CREATE), db.WithCreateNotBefore(10*time.Second))) + } }) } diff --git a/internal/auth/password/repository_password.go b/internal/auth/password/repository_password.go new file mode 100644 index 000000000..8e48f9136 --- /dev/null +++ b/internal/auth/password/repository_password.go @@ -0,0 +1,91 @@ +package password + +import ( + "context" + "crypto/subtle" + "fmt" + + "github.com/hashicorp/watchtower/internal/db" + "golang.org/x/crypto/argon2" +) + +// Authenticate authenticates userName and password match for userName in +// authMethodId. The account for the userName is returned if authentication +// is successful. Returns nil if authentication fails. +// +// The CredentialId in the returned account represents a user's current +// password. A new CredentialId is generated when a user's password is +// changed and the old one is deleted. +// +// Authenticate will update the stored values for password to the current +// password settings for authMethodId if authentication is successful and +// the stored values are not using the current password settings. +func (r *Repository) Authenticate(ctx context.Context, authMethodId string, userName string, password string) (*Account, error) { + if authMethodId == "" { + return nil, fmt.Errorf("password authenticate: no authMethodId: %w", db.ErrInvalidParameter) + } + if userName == "" { + return nil, fmt.Errorf("password authenticate: no userName: %w", db.ErrInvalidParameter) + } + if password == "" { + return nil, fmt.Errorf("password authenticate: no password: %w", db.ErrInvalidParameter) + } + + type AuthAccount struct { + *Account + *Argon2Credential + *Argon2Configuration + } + + var accts []AuthAccount + + tx, err := r.reader.DB() + if err != nil { + return nil, err + } + rows, err := tx.Query(authenticateQuery, authMethodId, userName) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var aa AuthAccount + if err := r.reader.ScanRows(rows, &aa); err != nil { + return nil, err + } + accts = append(accts, aa) + } + + var acct AuthAccount + switch { + case len(accts) == 0: + return nil, nil + case len(accts) > 1: + // this should never happen + return nil, fmt.Errorf("authenticate: multiple accounts returned for user name") + default: + acct = accts[0] + } + + if err := acct.decrypt(ctx, r.wrapper); err != nil { + return nil, fmt.Errorf("authenticate: credential: cannot decrypt value: %w", err) + } + + inputKey := argon2.IDKey([]byte(password), acct.Salt, acct.Iterations, acct.Memory, uint8(acct.Threads), acct.KeyLength) + if subtle.ConstantTimeCompare(inputKey, acct.DerivedKey) == 0 { + return nil, nil + } + + // TODO(mgaffney) 07/2020: update stored credential if acct config is + // not the current config for authMethodId + return acct.Account, nil +} + +// ChangePassword updates the password for userName in authMethodId to new +// if old equals the stored password. The account for the userName is +// returned with a new CredentialId if password is successfully changed. +// +// Returns nil if old does not match the stored password for userName. +func (r *Repository) ChangePassword(ctx context.Context, authMethodId string, userName string, old, new string) (*Account, error) { + panic("not implemented") +} diff --git a/internal/auth/password/repository_password_test.go b/internal/auth/password/repository_password_test.go new file mode 100644 index 000000000..799eb32e4 --- /dev/null +++ b/internal/auth/password/repository_password_test.go @@ -0,0 +1,117 @@ +package password + +import ( + "context" + "errors" + "testing" + + "github.com/hashicorp/watchtower/internal/auth/password/store" + "github.com/hashicorp/watchtower/internal/db" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepository_Authenticate(t *testing.T) { + conn, _ := db.TestSetup(t, "postgres") + rw := db.New(conn) + wrapper := db.TestWrapper(t) + + authMethods := testAuthMethods(t, conn, 1) + authMethod := authMethods[0] + + inAcct := &Account{ + Account: &store.Account{ + AuthMethodId: authMethod.PublicId, + UserName: "kazmierczak", + }, + } + passwd := "12345678" + + repo, err := NewRepository(rw, rw, wrapper) + assert.NoError(t, err) + require.NotNil(t, repo) + outAcct, err := repo.CreateAccount(context.Background(), inAcct, WithPassword(passwd)) + assert.NoError(t, err) + require.NotNil(t, outAcct) + + type args struct { + authMethodId string + userName string + password string + } + + var tests = []struct { + name string + args args + want *Account + wantIsErr error + }{ + { + name: "invalid-no-authMethodId", + args: args{ + authMethodId: "", + userName: inAcct.UserName, + password: passwd, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-no-userName", + args: args{ + authMethodId: inAcct.AuthMethodId, + userName: "", + password: passwd, + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "invalid-no-password", + args: args{ + authMethodId: inAcct.AuthMethodId, + userName: inAcct.UserName, + password: "", + }, + wantIsErr: db.ErrInvalidParameter, + }, + { + name: "valid-authenticate", + args: args{ + authMethodId: inAcct.AuthMethodId, + userName: inAcct.UserName, + password: passwd, + }, + want: outAcct, + }, + { + name: "wrong-password", + args: args{ + authMethodId: inAcct.AuthMethodId, + userName: inAcct.UserName, + password: "foobar", + }, + want: nil, + wantIsErr: nil, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + assert, require := assert.New(t), require.New(t) + authAcct, err := repo.Authenticate(context.Background(), tt.args.authMethodId, tt.args.userName, tt.args.password) + if tt.wantIsErr != nil { + assert.Truef(errors.Is(err, tt.wantIsErr), "want err: %q got: %q", tt.wantIsErr, err) + assert.Nil(authAcct, "returned account") + return + } + require.NoError(err) + if tt.want == nil { + assert.Nil(authAcct) + return + } + require.NotNil(authAcct, "returned account") + assert.NotEmpty(authAcct.CredentialId, "CredentialId") + assert.Equal(tt.args.authMethodId, authAcct.AuthMethodId, "authMethodId") + assert.Equal(tt.args.userName, authAcct.UserName, "UserName") + }) + } +} diff --git a/internal/auth/password/store/argon2.pb.go b/internal/auth/password/store/argon2.pb.go index b38df32d8..0fb71c00a 100644 --- a/internal/auth/password/store/argon2.pb.go +++ b/internal/auth/password/store/argon2.pb.go @@ -158,6 +158,132 @@ func (x *Argon2Configuration) GetKeyLength() uint32 { return 0 } +type Argon2Credential struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // @inject_tag: `gorm:"primary_key"` + PrivateId string `protobuf:"bytes,1,opt,name=private_id,json=privateId,proto3" json:"private_id,omitempty" gorm:"primary_key"` + // The create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + CreateTime *timestamp.Timestamp `protobuf:"bytes,2,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty" gorm:"default:current_timestamp"` + // The update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + UpdateTime *timestamp.Timestamp `protobuf:"bytes,3,opt,name=update_time,json=updateTime,proto3" json:"update_time,omitempty" gorm:"default:current_timestamp"` + // @inject_tag: `gorm:"not_null"` + PasswordAccountId string `protobuf:"bytes,4,opt,name=password_account_id,json=passwordAccountId,proto3" json:"password_account_id,omitempty" gorm:"not_null"` + // @inject_tag: `gorm:"not_null"` + PasswordConfId string `protobuf:"bytes,5,opt,name=password_conf_id,json=passwordConfId,proto3" json:"password_conf_id,omitempty" gorm:"not_null"` + // @inject_tag: `gorm:"not_null"` + PasswordMethodId string `protobuf:"bytes,6,opt,name=password_method_id,json=passwordMethodId,proto3" json:"password_method_id,omitempty" gorm:"not_null"` + // ct_salt is the encrypted salt which is stored in the database. + // @inject_tag: `gorm:"column:salt;not_null" wrapping:"ct,entry_salt"` + CtSalt []byte `protobuf:"bytes,7,opt,name=ct_salt,json=ctSalt,proto3" json:"ct_salt,omitempty" gorm:"column:salt;not_null" wrapping:"ct,entry_salt"` + // salt is the unencrypted salt which is not stored in the database. + // @inject_tag: `gorm:"-" wrapping:"pt,entry_salt"` + Salt []byte `protobuf:"bytes,8,opt,name=salt,proto3" json:"salt,omitempty" gorm:"-" wrapping:"pt,entry_salt"` + // derived_key is the derived key produced by the Argon2id key + // derivation function. + // @inject_tag: `gorm:"not_null"` + DerivedKey []byte `protobuf:"bytes,9,opt,name=derived_key,json=derivedKey,proto3" json:"derived_key,omitempty" gorm:"not_null"` +} + +func (x *Argon2Credential) Reset() { + *x = Argon2Credential{} + if protoimpl.UnsafeEnabled { + mi := &file_controller_storage_auth_password_store_v1_argon2_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Argon2Credential) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Argon2Credential) ProtoMessage() {} + +func (x *Argon2Credential) ProtoReflect() protoreflect.Message { + mi := &file_controller_storage_auth_password_store_v1_argon2_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Argon2Credential.ProtoReflect.Descriptor instead. +func (*Argon2Credential) Descriptor() ([]byte, []int) { + return file_controller_storage_auth_password_store_v1_argon2_proto_rawDescGZIP(), []int{1} +} + +func (x *Argon2Credential) GetPrivateId() string { + if x != nil { + return x.PrivateId + } + return "" +} + +func (x *Argon2Credential) GetCreateTime() *timestamp.Timestamp { + if x != nil { + return x.CreateTime + } + return nil +} + +func (x *Argon2Credential) GetUpdateTime() *timestamp.Timestamp { + if x != nil { + return x.UpdateTime + } + return nil +} + +func (x *Argon2Credential) GetPasswordAccountId() string { + if x != nil { + return x.PasswordAccountId + } + return "" +} + +func (x *Argon2Credential) GetPasswordConfId() string { + if x != nil { + return x.PasswordConfId + } + return "" +} + +func (x *Argon2Credential) GetPasswordMethodId() string { + if x != nil { + return x.PasswordMethodId + } + return "" +} + +func (x *Argon2Credential) GetCtSalt() []byte { + if x != nil { + return x.CtSalt + } + return nil +} + +func (x *Argon2Credential) GetSalt() []byte { + if x != nil { + return x.Salt + } + return nil +} + +func (x *Argon2Credential) GetDerivedKey() []byte { + if x != nil { + return x.DerivedKey + } + return nil +} + var File_controller_storage_auth_password_store_v1_argon2_proto protoreflect.FileDescriptor var file_controller_storage_auth_password_store_v1_argon2_proto_rawDesc = []byte{ @@ -190,12 +316,38 @@ var file_controller_storage_auth_password_store_v1_argon2_proto_rawDesc = []byte 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0a, 0x73, 0x61, 0x6c, 0x74, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x6b, 0x65, 0x79, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x6b, - 0x65, 0x79, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x44, 0x5a, 0x42, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, 0x63, 0x6f, 0x72, 0x70, - 0x2f, 0x77, 0x61, 0x74, 0x63, 0x68, 0x74, 0x6f, 0x77, 0x65, 0x72, 0x2f, 0x69, 0x6e, 0x74, 0x65, - 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2f, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, - 0x72, 0x64, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x65, 0x79, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, 0xa1, 0x03, 0x0a, 0x10, 0x41, 0x72, 0x67, + 0x6f, 0x6e, 0x32, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x12, 0x1d, 0x0a, + 0x0a, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x49, 0x64, 0x12, 0x4b, 0x0a, 0x0b, + 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x0b, 0x32, 0x2a, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, + 0x74, 0x6f, 0x72, 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, + 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x63, + 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x4b, 0x0a, 0x0b, 0x75, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2a, + 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x6c, 0x65, 0x72, 0x2e, 0x73, 0x74, 0x6f, 0x72, + 0x61, 0x67, 0x65, 0x2e, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x76, 0x31, + 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x5f, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x11, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x41, 0x63, 0x63, + 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, + 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x49, 0x64, + 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x6d, 0x65, 0x74, + 0x68, 0x6f, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x4d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x49, 0x64, 0x12, 0x17, + 0x0a, 0x07, 0x63, 0x74, 0x5f, 0x73, 0x61, 0x6c, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0c, 0x52, + 0x06, 0x63, 0x74, 0x53, 0x61, 0x6c, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x18, + 0x08, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x73, 0x61, 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x64, + 0x65, 0x72, 0x69, 0x76, 0x65, 0x64, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0c, + 0x52, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x65, 0x64, 0x4b, 0x65, 0x79, 0x42, 0x44, 0x5a, 0x42, + 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x68, 0x61, 0x73, 0x68, 0x69, + 0x63, 0x6f, 0x72, 0x70, 0x2f, 0x77, 0x61, 0x74, 0x63, 0x68, 0x74, 0x6f, 0x77, 0x65, 0x72, 0x2f, + 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2f, 0x70, 0x61, + 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x2f, 0x73, 0x74, 0x6f, 0x72, 0x65, 0x3b, 0x73, 0x74, 0x6f, + 0x72, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -210,18 +362,21 @@ func file_controller_storage_auth_password_store_v1_argon2_proto_rawDescGZIP() [ return file_controller_storage_auth_password_store_v1_argon2_proto_rawDescData } -var file_controller_storage_auth_password_store_v1_argon2_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_controller_storage_auth_password_store_v1_argon2_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_controller_storage_auth_password_store_v1_argon2_proto_goTypes = []interface{}{ (*Argon2Configuration)(nil), // 0: controller.storage.auth.password.store.v1.Argon2Configuration - (*timestamp.Timestamp)(nil), // 1: controller.storage.timestamp.v1.Timestamp + (*Argon2Credential)(nil), // 1: controller.storage.auth.password.store.v1.Argon2Credential + (*timestamp.Timestamp)(nil), // 2: controller.storage.timestamp.v1.Timestamp } var file_controller_storage_auth_password_store_v1_argon2_proto_depIdxs = []int32{ - 1, // 0: controller.storage.auth.password.store.v1.Argon2Configuration.create_time:type_name -> controller.storage.timestamp.v1.Timestamp - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 2, // 0: controller.storage.auth.password.store.v1.Argon2Configuration.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 2, // 1: controller.storage.auth.password.store.v1.Argon2Credential.create_time:type_name -> controller.storage.timestamp.v1.Timestamp + 2, // 2: controller.storage.auth.password.store.v1.Argon2Credential.update_time:type_name -> controller.storage.timestamp.v1.Timestamp + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_controller_storage_auth_password_store_v1_argon2_proto_init() } @@ -242,6 +397,18 @@ func file_controller_storage_auth_password_store_v1_argon2_proto_init() { return nil } } + file_controller_storage_auth_password_store_v1_argon2_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*Argon2Credential); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } } type x struct{} out := protoimpl.TypeBuilder{ @@ -249,7 +416,7 @@ func file_controller_storage_auth_password_store_v1_argon2_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_controller_storage_auth_password_store_v1_argon2_proto_rawDesc, NumEnums: 0, - NumMessages: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/internal/db/migrations/postgres.gen.go b/internal/db/migrations/postgres.gen.go index 400e2882a..7e32bac15 100644 --- a/internal/db/migrations/postgres.gen.go +++ b/internal/db/migrations/postgres.gen.go @@ -1873,6 +1873,7 @@ commit; bytes: []byte(` begin; + drop table auth_password_argon2_cred; drop table auth_password_argon2_conf; commit; @@ -1913,6 +1914,7 @@ begin; on update cascade deferrable initially deferred ); + create or replace function read_only_auth_password_argon2_conf() returns trigger @@ -1933,9 +1935,42 @@ begin; before insert on auth_password_argon2_conf for each row execute procedure insert_auth_password_conf_subtype(); + create table auth_password_argon2_cred ( + private_id wt_private_id primary key + references auth_password_credential (private_id) + on delete cascade + on update cascade, + password_account_id wt_public_id not null, + password_conf_id wt_private_id not null, + -- NOTE(mgaffney): The password_method_id type is not wt_public_id because + -- the domain check is executed before the insert trigger which retrieves + -- the password_method_id causing an insert to fail. + password_method_id text not null, + create_time wt_timestamp, + update_time wt_timestamp, + salt bytea not null -- cannot be changed unless derived_key is changed too + check(length(salt) > 0), + derived_key bytea not null + check(length(derived_key) > 0), + foreign key (password_method_id, password_conf_id) + references auth_password_argon2_conf (password_method_id, private_id) + on delete cascade + on update cascade, + foreign key (password_method_id, password_conf_id, password_account_id) + references auth_password_credential (password_method_id, password_conf_id, password_account_id) + on delete cascade + on update cascade + ); + + create trigger + insert_auth_password_credential_subtype + before insert on auth_password_argon2_cred + for each row execute procedure insert_auth_password_credential_subtype(); + -- -- triggers for time columns -- + create trigger immutable_columns before @@ -1948,6 +1983,24 @@ begin; insert on auth_password_argon2_conf for each row execute procedure default_create_time(); + create trigger + update_time_column + before + update on auth_password_argon2_cred + for each row execute procedure update_time_column(); + + create trigger + immutable_columns + before + update on auth_password_argon2_cred + for each row execute procedure immutable_columns('create_time'); + + create trigger + default_create_time_column + before + insert on auth_password_argon2_cred + for each row execute procedure default_create_time(); + -- The tickets for oplog are the subtypes not the base types because no updates -- are done to any values in the base types. insert into oplog_ticket diff --git a/internal/db/migrations/postgres/13_auth_password_argon.down.sql b/internal/db/migrations/postgres/13_auth_password_argon.down.sql index ba4f8bb75..209506cca 100644 --- a/internal/db/migrations/postgres/13_auth_password_argon.down.sql +++ b/internal/db/migrations/postgres/13_auth_password_argon.down.sql @@ -1,5 +1,6 @@ begin; + drop table auth_password_argon2_cred; drop table auth_password_argon2_conf; commit; diff --git a/internal/db/migrations/postgres/13_auth_password_argon.up.sql b/internal/db/migrations/postgres/13_auth_password_argon.up.sql index 284e733d6..cacc5727d 100644 --- a/internal/db/migrations/postgres/13_auth_password_argon.up.sql +++ b/internal/db/migrations/postgres/13_auth_password_argon.up.sql @@ -29,6 +29,7 @@ begin; on update cascade deferrable initially deferred ); + create or replace function read_only_auth_password_argon2_conf() returns trigger @@ -49,9 +50,42 @@ begin; before insert on auth_password_argon2_conf for each row execute procedure insert_auth_password_conf_subtype(); + create table auth_password_argon2_cred ( + private_id wt_private_id primary key + references auth_password_credential (private_id) + on delete cascade + on update cascade, + password_account_id wt_public_id not null, + password_conf_id wt_private_id not null, + -- NOTE(mgaffney): The password_method_id type is not wt_public_id because + -- the domain check is executed before the insert trigger which retrieves + -- the password_method_id causing an insert to fail. + password_method_id text not null, + create_time wt_timestamp, + update_time wt_timestamp, + salt bytea not null -- cannot be changed unless derived_key is changed too + check(length(salt) > 0), + derived_key bytea not null + check(length(derived_key) > 0), + foreign key (password_method_id, password_conf_id) + references auth_password_argon2_conf (password_method_id, private_id) + on delete cascade + on update cascade, + foreign key (password_method_id, password_conf_id, password_account_id) + references auth_password_credential (password_method_id, password_conf_id, password_account_id) + on delete cascade + on update cascade + ); + + create trigger + insert_auth_password_credential_subtype + before insert on auth_password_argon2_cred + for each row execute procedure insert_auth_password_credential_subtype(); + -- -- triggers for time columns -- + create trigger immutable_columns before @@ -64,6 +98,24 @@ begin; insert on auth_password_argon2_conf for each row execute procedure default_create_time(); + create trigger + update_time_column + before + update on auth_password_argon2_cred + for each row execute procedure update_time_column(); + + create trigger + immutable_columns + before + update on auth_password_argon2_cred + for each row execute procedure immutable_columns('create_time'); + + create trigger + default_create_time_column + before + insert on auth_password_argon2_cred + for each row execute procedure default_create_time(); + -- The tickets for oplog are the subtypes not the base types because no updates -- are done to any values in the base types. insert into oplog_ticket diff --git a/internal/proto/local/controller/storage/auth/password/store/v1/argon2.proto b/internal/proto/local/controller/storage/auth/password/store/v1/argon2.proto index b99418c01..67b264051 100644 --- a/internal/proto/local/controller/storage/auth/password/store/v1/argon2.proto +++ b/internal/proto/local/controller/storage/auth/password/store/v1/argon2.proto @@ -50,3 +50,36 @@ message Argon2Configuration { // @inject_tag: `gorm:"default:null"` uint32 key_length = 8; } + +message Argon2Credential { + // @inject_tag: `gorm:"primary_key"` + string private_id = 1; + + // The create_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp create_time = 2; + + // The update_time is set by the database. + // @inject_tag: `gorm:"default:current_timestamp"` + timestamp.v1.Timestamp update_time = 3; + + // @inject_tag: `gorm:"not_null"` + string password_account_id = 4; + // @inject_tag: `gorm:"not_null"` + string password_conf_id = 5; + // @inject_tag: `gorm:"not_null"` + string password_method_id = 6; + + // ct_salt is the encrypted salt which is stored in the database. + // @inject_tag: `gorm:"column:salt;not_null" wrapping:"ct,entry_salt"` + bytes ct_salt = 7; + + // salt is the unencrypted salt which is not stored in the database. + // @inject_tag: `gorm:"-" wrapping:"pt,entry_salt"` + bytes salt = 8; + + // derived_key is the derived key produced by the Argon2id key + // derivation function. + // @inject_tag: `gorm:"not_null"` + bytes derived_key = 9; +}