Skip to content

Commit ec74509

Browse files
committed
feat(credential/static): Implement CRUDL support for password credentials (#6163)
1 parent 82745e1 commit ec74509

File tree

14 files changed

+1881
-155
lines changed

14 files changed

+1881
-155
lines changed

internal/credential/public_ids.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,11 @@ func NewUsernamePasswordDomainCredentialId(ctx context.Context) (string, error)
5151
return id, nil
5252
}
5353

54-
// PasswordCredentialId generates a new public ID for a password credential.
55-
func PasswordCredentialId(ctx context.Context) (string, error) {
54+
// NewPasswordCredentialId generates a new public ID for a password credential.
55+
func NewPasswordCredentialId(ctx context.Context) (string, error) {
5656
id, err := db.NewPublicId(ctx, globals.PasswordCredentialPrefix)
5757
if err != nil {
58-
return "", errors.Wrap(ctx, err, "credential.PasswordCredentialId")
58+
return "", errors.Wrap(ctx, err, "credential.NewPasswordCredentialId")
5959
}
6060
return id, nil
6161
}

internal/credential/static/credential.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ func (c *listCredentialResult) toCredential(ctx context.Context) (credential.Sta
9292
cred.PasswordHmac = []byte(c.Hmac1)
9393
}
9494
return cred, nil
95+
case "p":
96+
cred := &PasswordCredential{
97+
PasswordCredential: &store.PasswordCredential{
98+
PublicId: c.PublicId,
99+
StoreId: c.StoreId,
100+
Name: c.Name,
101+
Description: c.Description,
102+
CreateTime: c.CreateTime,
103+
UpdateTime: c.UpdateTime,
104+
Version: uint32(c.Version),
105+
KeyId: c.KeyId,
106+
},
107+
}
108+
// Assign byte slices only if the string isn't empty
109+
if c.Hmac1 != "" {
110+
cred.PasswordHmac = []byte(c.Hmac1)
111+
}
112+
return cred, nil
95113
case "ssh":
96114
cred := &SshPrivateKeyCredential{
97115
SshPrivateKeyCredential: &store.SshPrivateKeyCredential{
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package static
5+
6+
import (
7+
"context"
8+
9+
"github.com/hashicorp/boundary/internal/credential"
10+
"github.com/hashicorp/boundary/internal/credential/static/store"
11+
"github.com/hashicorp/boundary/internal/db/timestamp"
12+
"github.com/hashicorp/boundary/internal/errors"
13+
"github.com/hashicorp/boundary/internal/libs/crypto"
14+
"github.com/hashicorp/boundary/internal/oplog"
15+
"github.com/hashicorp/boundary/internal/types/resource"
16+
wrapping "github.com/hashicorp/go-kms-wrapping/v2"
17+
"github.com/hashicorp/go-kms-wrapping/v2/extras/structwrapping"
18+
"google.golang.org/protobuf/proto"
19+
)
20+
21+
var _ credential.Static = (*PasswordCredential)(nil)
22+
23+
// PasswordCredential contains the credential with a password.
24+
// It is owned by a credential store.
25+
type PasswordCredential struct {
26+
*store.PasswordCredential
27+
tableName string `gorm:"-"`
28+
}
29+
30+
// NewPasswordCredential creates a new in memory static Credential containing a
31+
// password that is assigned to storeId. Name and description are the only
32+
// valid options. All other options are ignored.
33+
func NewPasswordCredential(
34+
storeId string,
35+
password credential.Password,
36+
opt ...Option,
37+
) (*PasswordCredential, error) {
38+
opts := getOpts(opt...)
39+
l := &PasswordCredential{
40+
PasswordCredential: &store.PasswordCredential{
41+
StoreId: storeId,
42+
Name: opts.withName,
43+
Description: opts.withDescription,
44+
Password: []byte(password),
45+
},
46+
}
47+
return l, nil
48+
}
49+
50+
func allocPasswordCredential() *PasswordCredential {
51+
return &PasswordCredential{
52+
PasswordCredential: &store.PasswordCredential{},
53+
}
54+
}
55+
56+
func (c *PasswordCredential) clone() *PasswordCredential {
57+
cp := proto.Clone(c.PasswordCredential)
58+
return &PasswordCredential{
59+
PasswordCredential: cp.(*store.PasswordCredential),
60+
}
61+
}
62+
63+
// TableName returns the table name.
64+
func (c *PasswordCredential) TableName() string {
65+
if c.tableName != "" {
66+
return c.tableName
67+
}
68+
return "credential_static_password_credential"
69+
}
70+
71+
// SetTableName sets the table name.
72+
func (c *PasswordCredential) SetTableName(n string) {
73+
c.tableName = n
74+
}
75+
76+
// GetResourceType returns the resource type of the Credential
77+
func (c *PasswordCredential) GetResourceType() resource.Type {
78+
return resource.Credential
79+
}
80+
81+
func (c *PasswordCredential) oplog(op oplog.OpType) oplog.Metadata {
82+
metadata := oplog.Metadata{
83+
"resource-public-id": []string{c.PublicId},
84+
"resource-type": []string{"credential-static-password"},
85+
"op-type": []string{op.String()},
86+
}
87+
if c.StoreId != "" {
88+
metadata["store-id"] = []string{c.StoreId}
89+
}
90+
return metadata
91+
}
92+
93+
func (c *PasswordCredential) encrypt(ctx context.Context, cipher wrapping.Wrapper) error {
94+
const op = "static.(PasswordCredential).encrypt"
95+
if len(c.Password) == 0 {
96+
return errors.New(ctx, errors.InvalidParameter, op, "no password defined")
97+
}
98+
if err := structwrapping.WrapStruct(ctx, cipher, c.PasswordCredential, nil); err != nil {
99+
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt))
100+
}
101+
keyId, err := cipher.KeyId(ctx)
102+
if err != nil {
103+
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Encrypt), errors.WithMsg("error reading cipher key id"))
104+
}
105+
c.KeyId = keyId
106+
if err := c.hmacPassword(ctx, cipher); err != nil {
107+
return errors.Wrap(ctx, err, op)
108+
}
109+
return nil
110+
}
111+
112+
func (c *PasswordCredential) decrypt(ctx context.Context, cipher wrapping.Wrapper) error {
113+
const op = "static.(PasswordCredential).decrypt"
114+
if err := structwrapping.UnwrapStruct(ctx, cipher, c.PasswordCredential, nil); err != nil {
115+
return errors.Wrap(ctx, err, op, errors.WithCode(errors.Decrypt))
116+
}
117+
return nil
118+
}
119+
120+
func (c *PasswordCredential) hmacPassword(ctx context.Context, cipher wrapping.Wrapper) error {
121+
const op = "static.(PasswordCredential).hmacPassword"
122+
if cipher == nil {
123+
return errors.New(ctx, errors.InvalidParameter, op, "missing cipher")
124+
}
125+
hm, err := crypto.HmacSha256(ctx, c.Password, cipher, []byte(c.StoreId), nil, crypto.WithEd25519())
126+
if err != nil {
127+
return errors.Wrap(ctx, err, op)
128+
}
129+
c.PasswordHmac = []byte(hm)
130+
return nil
131+
}
132+
133+
type deletedPasswordCredential struct {
134+
PublicId string `gorm:"primary_key"`
135+
DeleteTime *timestamp.Timestamp
136+
}
137+
138+
// TableName returns the tablename to override the default gorm table name
139+
func (s *deletedPasswordCredential) TableName() string {
140+
return "credential_static_password_credential_deleted"
141+
}
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Copyright (c) HashiCorp, Inc.
2+
// SPDX-License-Identifier: BUSL-1.1
3+
4+
package static
5+
6+
import (
7+
"context"
8+
"testing"
9+
10+
"github.com/google/go-cmp/cmp"
11+
"github.com/hashicorp/boundary/internal/credential"
12+
"github.com/hashicorp/boundary/internal/credential/static/store"
13+
"github.com/hashicorp/boundary/internal/db"
14+
"github.com/hashicorp/boundary/internal/iam"
15+
"github.com/hashicorp/boundary/internal/kms"
16+
"github.com/hashicorp/boundary/internal/libs/crypto"
17+
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
19+
"google.golang.org/protobuf/testing/protocmp"
20+
)
21+
22+
func TestPasswordCredential_New(t *testing.T) {
23+
t.Parallel()
24+
conn, _ := db.TestSetup(t, "postgres")
25+
wrapper := db.TestWrapper(t)
26+
kkms := kms.TestKms(t, conn, wrapper)
27+
rw := db.New(conn)
28+
29+
_, prj := iam.TestScopes(t, iam.TestRepo(t, conn, wrapper))
30+
cs := TestCredentialStore(t, conn, wrapper, prj.PublicId)
31+
32+
type args struct {
33+
password credential.Password
34+
storeId string
35+
options []Option
36+
}
37+
38+
tests := []struct {
39+
name string
40+
args args
41+
want *PasswordCredential
42+
wantCreateErr bool
43+
wantEncryptErr bool
44+
}{
45+
{
46+
name: "missing-password",
47+
args: args{
48+
storeId: cs.PublicId,
49+
},
50+
want: allocPasswordCredential(),
51+
wantEncryptErr: true,
52+
},
53+
{
54+
name: "missing-store-id",
55+
args: args{
56+
password: "test-pass",
57+
},
58+
want: allocPasswordCredential(),
59+
wantCreateErr: true,
60+
},
61+
{
62+
name: "valid-no-options",
63+
args: args{
64+
password: "test-pass",
65+
storeId: cs.PublicId,
66+
},
67+
want: &PasswordCredential{
68+
PasswordCredential: &store.PasswordCredential{
69+
Password: []byte("test-pass"),
70+
StoreId: cs.PublicId,
71+
},
72+
},
73+
},
74+
{
75+
name: "valid-with-name",
76+
args: args{
77+
password: "test-pass",
78+
storeId: cs.PublicId,
79+
options: []Option{WithName("my-credential")},
80+
},
81+
want: &PasswordCredential{
82+
PasswordCredential: &store.PasswordCredential{
83+
Password: []byte("test-pass"),
84+
StoreId: cs.PublicId,
85+
Name: "my-credential",
86+
},
87+
},
88+
},
89+
{
90+
name: "valid-with-description",
91+
args: args{
92+
password: "test-pass",
93+
storeId: cs.PublicId,
94+
options: []Option{WithDescription("my-credential-description")},
95+
},
96+
want: &PasswordCredential{
97+
PasswordCredential: &store.PasswordCredential{
98+
Password: []byte("test-pass"),
99+
StoreId: cs.PublicId,
100+
Description: "my-credential-description",
101+
},
102+
},
103+
},
104+
}
105+
for _, tt := range tests {
106+
tt := tt
107+
t.Run(tt.name, func(t *testing.T) {
108+
assert, require := assert.New(t), require.New(t)
109+
ctx := context.Background()
110+
111+
got, err := NewPasswordCredential(tt.args.storeId, tt.args.password, tt.args.options...)
112+
113+
require.NoError(err)
114+
require.NotNil(got)
115+
assert.Emptyf(got.PublicId, "PublicId set")
116+
id, err := credential.NewPasswordCredentialId(ctx)
117+
require.NoError(err)
118+
119+
tt.want.PublicId = id
120+
got.PublicId = id
121+
122+
databaseWrapper, err := kkms.GetWrapper(context.Background(), prj.PublicId, kms.KeyPurposeDatabase)
123+
require.NoError(err)
124+
125+
err = got.encrypt(ctx, databaseWrapper)
126+
if tt.wantEncryptErr {
127+
require.Error(err)
128+
return
129+
}
130+
assert.NoError(err)
131+
132+
err = rw.Create(context.Background(), got)
133+
if tt.wantCreateErr {
134+
require.Error(err)
135+
return
136+
}
137+
assert.NoError(err)
138+
139+
got2 := allocPasswordCredential()
140+
got2.PublicId = id
141+
assert.Equal(id, got2.GetPublicId())
142+
require.NoError(rw.LookupById(ctx, got2))
143+
144+
err = got2.decrypt(ctx, databaseWrapper)
145+
require.NoError(err)
146+
147+
// Timestamps and version are automatically set
148+
tt.want.CreateTime = got2.CreateTime
149+
tt.want.UpdateTime = got2.UpdateTime
150+
tt.want.Version = got2.Version
151+
152+
// KeyId is allocated via kms no need to validate in this test
153+
tt.want.KeyId = got2.KeyId
154+
got2.CtPassword = nil
155+
156+
// encrypt also calculates the hmac, validate it is correct
157+
hm, err := crypto.HmacSha256(ctx, got.Password, databaseWrapper, []byte(got.StoreId), nil, crypto.WithEd25519())
158+
require.NoError(err)
159+
tt.want.PasswordHmac = []byte(hm)
160+
161+
assert.Empty(cmp.Diff(tt.want, got2.clone(), protocmp.Transform()))
162+
})
163+
}
164+
}

0 commit comments

Comments
 (0)