Skip to content

Commit 3ff7d6f

Browse files
authored
[ARCH-332] KMS keystore support (#1767)
* Wip * Simplify * Simpler * Fix pubkey * Testing * test * Lint * Expore ASN utilities * Linter * Old cli thing * Simpler cli * Lint * More lint * Lint and simpler interface comp * Comments
1 parent bd9e1b4 commit 3ff7d6f

11 files changed

Lines changed: 786 additions & 55 deletions

File tree

keystore/cli/cli.go

Lines changed: 96 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ import (
1212
"time"
1313

1414
"github.com/jmoiron/sqlx"
15-
_ "github.com/lib/pq"
15+
_ "github.com/lib/pq" // Register postgres driver
1616
"github.com/spf13/cobra"
1717

1818
ks "github.com/smartcontractkit/chainlink-common/keystore"
19+
"github.com/smartcontractkit/chainlink-common/keystore/kms"
1920
"github.com/smartcontractkit/chainlink-common/keystore/pgstore"
2021
)
2122

@@ -27,12 +28,15 @@ func NewRootCmd() *cobra.Command {
2728
cmd := &cobra.Command{
2829
Use: "keys",
2930
Long: `
30-
CLI for managing keystore keys. Must specify KEYSTORE_FILE_PATH or KEYSTORE_DB_URL
31-
and KEYSTORE_PASSWORD in order to load the keystore.
31+
CLI for managing keystore keys.
3232
33-
KEYSTORE_FILE_PATH: is the path to the keystore file, can be empty for a new keystore.
34-
File must already exist. Example to create a new keystore file: touch ./keystore.json
33+
If KEYSTORE_KMS_PROFILE is set, will load the keystore from KMS.
34+
KEYSTORE_KMS_PROFILE: is the AWS profile to use for KMS (region will be taken from the profile).
3535
36+
Otherwise, will load the keystore from a file or database.
37+
KEYSTORE_PASSWORD: password used to encrypt the key material before storage, must be provided.
38+
KEYSTORE_FILE_PATH: is the path to the keystore file, can be empty for a new keystore.
39+
File must already exist. Example to create a new keystore file: touch ./keystore.json. Either KEYSTORE_FILE_PATH or KEYSTORE_DB_URL must be set.
3640
KEYSTORE_DB_URL: is the postgres connection URL. Only use this if your keystore is stored in a pg database.
3741
Requires a pg database with a 'encrypted_keystore' table with the following schema:
3842
CREATE TABLE IF NOT EXISTS encrypted_keystore (
@@ -42,17 +46,42 @@ CREATE TABLE IF NOT EXISTS encrypted_keystore (
4246
updated_at timestamptz NOT NULL DEFAULT NOW(),
4347
encrypted_data BYTEA NOT NULL
4448
);
45-
46-
KEYSTORE_PASSWORD is the password used to encrypt the key material before storage.
4749
`,
4850
Short: "CLI for managing keystore keys",
4951
SilenceUsage: true,
5052
}
51-
cmd.PersistentFlags().String("file-path", "", "Overrides KEYSTORE_FILE_PATH environment variable")
52-
cmd.PersistentFlags().String("db-url", "", "Overrides KEYSTORE_DB_URL environment variable")
53-
cmd.PersistentFlags().String("password", "", "Overrides KEYSTORE_PASSWORD environment variable. Not recommended as will leave shell traces.")
5453

55-
cmd.AddCommand(NewListCmd(), NewGetCmd(), NewCreateCmd(), NewDeleteCmd(), NewExportCmd(), NewImportCmd(), NewSetMetadataCmd(), NewSignCmd(), NewVerifyCmd(), NewEncryptCmd(), NewDecryptCmd())
54+
// Check if KMS profile is set - if so, hide commands that don't work with KMS
55+
isKMSMode := os.Getenv("KEYSTORE_KMS_PROFILE") != ""
56+
57+
// Commands that work with both regular keystore and KMS
58+
listCmd := NewListCmd()
59+
getCmd := NewGetCmd()
60+
signCmd := NewSignCmd()
61+
verifyCmd := NewVerifyCmd()
62+
63+
// Commands that only work with regular keystore (not KMS)
64+
createCmd := NewCreateCmd()
65+
deleteCmd := NewDeleteCmd()
66+
exportCmd := NewExportCmd()
67+
importCmd := NewImportCmd()
68+
setMetadataCmd := NewSetMetadataCmd()
69+
// Note these could potentially be supported with KMS, but not yet implemented.
70+
encryptCmd := NewEncryptCmd()
71+
decryptCmd := NewDecryptCmd()
72+
73+
// Hide admin/encryption commands when using KMS (keys are managed externally)
74+
if isKMSMode {
75+
createCmd.Hidden = true
76+
deleteCmd.Hidden = true
77+
exportCmd.Hidden = true
78+
importCmd.Hidden = true
79+
setMetadataCmd.Hidden = true
80+
encryptCmd.Hidden = true
81+
decryptCmd.Hidden = true
82+
}
83+
84+
cmd.AddCommand(listCmd, getCmd, createCmd, deleteCmd, exportCmd, importCmd, setMetadataCmd, signCmd, verifyCmd, encryptCmd, decryptCmd)
5685
return cmd
5786
}
5887

@@ -62,7 +91,7 @@ func NewListCmd() *cobra.Command {
6291
RunE: func(cmd *cobra.Command, args []string) error {
6392
ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout)
6493
defer cancel()
65-
k, err := loadKeystore(ctx, cmd)
94+
k, err := loadKeystoreSignerReader(ctx, cmd)
6695
if err != nil {
6796
return err
6897
}
@@ -85,7 +114,13 @@ func NewGetCmd() *cobra.Command {
85114
cmd := cobra.Command{
86115
Use: "get", Short: "Get keys",
87116
RunE: func(cmd *cobra.Command, args []string) error {
88-
return runKeystoreCommand[ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.GetKeysRequest) (ks.GetKeysResponse, error) {
117+
return runKeystoreCommand[interface {
118+
ks.Reader
119+
ks.Signer
120+
}, ks.GetKeysRequest, ks.GetKeysResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k interface {
121+
ks.Reader
122+
ks.Signer
123+
}, req ks.GetKeysRequest) (ks.GetKeysResponse, error) {
89124
return k.GetKeys(ctx, req)
90125
})
91126
},
@@ -99,7 +134,7 @@ func NewCreateCmd() *cobra.Command {
99134
cmd := cobra.Command{
100135
Use: "create", Short: "Create a key",
101136
RunE: func(cmd *cobra.Command, args []string) error {
102-
return runKeystoreCommand[ks.CreateKeysRequest, ks.CreateKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.CreateKeysRequest) (ks.CreateKeysResponse, error) {
137+
return runKeystoreCommand[ks.Keystore, ks.CreateKeysRequest, ks.CreateKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.CreateKeysRequest) (ks.CreateKeysResponse, error) {
103138
return k.CreateKeys(ctx, req)
104139
})
105140
},
@@ -128,7 +163,7 @@ func NewDeleteCmd() *cobra.Command {
128163
}
129164
if !confirmYes {
130165
// Prompt for confirmation on stdin
131-
_, err = cmd.OutOrStderr().Write([]byte(fmt.Sprintf("This will permanently delete keys: %v. Type 'yes' to confirm: ", strings.Join(req.KeyNames, ", "))))
166+
_, err = fmt.Fprintf(cmd.OutOrStderr(), "This will permanently delete keys: %v. Type 'yes' to confirm: ", strings.Join(req.KeyNames, ", "))
132167
if err != nil {
133168
return err
134169
}
@@ -159,7 +194,7 @@ func NewExportCmd() *cobra.Command {
159194
cmd := cobra.Command{
160195
Use: "export", Short: "Export a key to an encrypted JSON file",
161196
RunE: func(cmd *cobra.Command, args []string) error {
162-
return runKeystoreCommand[ks.ExportKeysRequest, ks.ExportKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.ExportKeysRequest) (ks.ExportKeysResponse, error) {
197+
return runKeystoreCommand[ks.Keystore, ks.ExportKeysRequest, ks.ExportKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.ExportKeysRequest) (ks.ExportKeysResponse, error) {
163198
return k.ExportKeys(ctx, req)
164199
})
165200
},
@@ -173,7 +208,7 @@ func NewImportCmd() *cobra.Command {
173208
cmd := cobra.Command{
174209
Use: "import", Short: "Import an encrypted key JSON file",
175210
RunE: func(cmd *cobra.Command, args []string) error {
176-
return runKeystoreCommand[ks.ImportKeysRequest, ks.ImportKeysResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.ImportKeysRequest) (ks.ImportKeysResponse, error) {
211+
return runKeystoreCommand[ks.Keystore, ks.ImportKeysRequest, ks.ImportKeysResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.ImportKeysRequest) (ks.ImportKeysResponse, error) {
177212
return k.ImportKeys(ctx, req)
178213
})
179214
},
@@ -187,7 +222,7 @@ func NewSetMetadataCmd() *cobra.Command {
187222
cmd := cobra.Command{
188223
Use: "set-metadata", Short: "Set metadata for keys",
189224
RunE: func(cmd *cobra.Command, args []string) error {
190-
return runKeystoreCommand[ks.SetMetadataRequest, ks.SetMetadataResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.SetMetadataRequest) (ks.SetMetadataResponse, error) {
225+
return runKeystoreCommand[ks.Keystore, ks.SetMetadataRequest, ks.SetMetadataResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.SetMetadataRequest) (ks.SetMetadataResponse, error) {
191226
return k.SetMetadata(ctx, req)
192227
})
193228
},
@@ -197,8 +232,13 @@ func NewSetMetadataCmd() *cobra.Command {
197232
return &cmd
198233
}
199234

200-
func runKeystoreCommand[Req any, Resp any](cmd *cobra.Command, args []string, fn func(ctx context.Context, k ks.Keystore,
201-
req Req) (Resp, error)) error {
235+
// runKeystoreCommandGeneric is a generic helper that runs a keystore command with a custom loader function.
236+
func runKeystoreCommand[K any, Req any, Resp any](
237+
cmd *cobra.Command,
238+
args []string,
239+
loader func(ctx context.Context, cmd *cobra.Command) (K, error),
240+
fn func(ctx context.Context, k K, req Req) (Resp, error),
241+
) error {
202242
jsonBytes, err := readJSONInput(cmd)
203243
if err != nil {
204244
return err
@@ -210,7 +250,7 @@ func runKeystoreCommand[Req any, Resp any](cmd *cobra.Command, args []string, fn
210250
}
211251
ctx, cancel := context.WithTimeout(cmd.Context(), KeystoreLoadTimeout)
212252
defer cancel()
213-
k, err := loadKeystore(ctx, cmd)
253+
k, err := loader(ctx, cmd)
214254
if err != nil {
215255
return err
216256
}
@@ -233,7 +273,13 @@ func NewSignCmd() *cobra.Command {
233273
cmd := cobra.Command{
234274
Use: "sign", Short: "Sign data with a key",
235275
RunE: func(cmd *cobra.Command, args []string) error {
236-
return runKeystoreCommand[ks.SignRequest, ks.SignResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.SignRequest) (ks.SignResponse, error) {
276+
return runKeystoreCommand[interface {
277+
ks.Reader
278+
ks.Signer
279+
}, ks.SignRequest, ks.SignResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k interface {
280+
ks.Reader
281+
ks.Signer
282+
}, req ks.SignRequest) (ks.SignResponse, error) {
237283
return k.Sign(ctx, req)
238284
})
239285
},
@@ -272,7 +318,13 @@ func NewVerifyCmd() *cobra.Command {
272318
cmd := cobra.Command{
273319
Use: "verify", Short: "Verify a signature",
274320
RunE: func(cmd *cobra.Command, args []string) error {
275-
return runKeystoreCommand[ks.VerifyRequest, ks.VerifyResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.VerifyRequest) (ks.VerifyResponse, error) {
321+
return runKeystoreCommand[interface {
322+
ks.Reader
323+
ks.Signer
324+
}, ks.VerifyRequest, ks.VerifyResponse](cmd, args, loadKeystoreSignerReader, func(ctx context.Context, k interface {
325+
ks.Reader
326+
ks.Signer
327+
}, req ks.VerifyRequest) (ks.VerifyResponse, error) {
276328
return k.Verify(ctx, req)
277329
})
278330
},
@@ -286,7 +338,7 @@ func NewEncryptCmd() *cobra.Command {
286338
cmd := cobra.Command{
287339
Use: "encrypt", Short: "Encrypt data to a remote public key",
288340
RunE: func(cmd *cobra.Command, args []string) error {
289-
return runKeystoreCommand[ks.EncryptRequest, ks.EncryptResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.EncryptRequest) (ks.EncryptResponse, error) {
341+
return runKeystoreCommand[ks.Keystore, ks.EncryptRequest, ks.EncryptResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.EncryptRequest) (ks.EncryptResponse, error) {
290342
return k.Encrypt(ctx, req)
291343
})
292344
},
@@ -325,7 +377,7 @@ func NewDecryptCmd() *cobra.Command {
325377
cmd := cobra.Command{
326378
Use: "decrypt", Short: "Decrypt data with a key",
327379
RunE: func(cmd *cobra.Command, args []string) error {
328-
return runKeystoreCommand[ks.DecryptRequest, ks.DecryptResponse](cmd, args, func(ctx context.Context, k ks.Keystore, req ks.DecryptRequest) (ks.DecryptResponse, error) {
380+
return runKeystoreCommand[ks.Keystore, ks.DecryptRequest, ks.DecryptResponse](cmd, args, loadKeystore, func(ctx context.Context, k ks.Keystore, req ks.DecryptRequest) (ks.DecryptResponse, error) {
329381
return k.Decrypt(ctx, req)
330382
})
331383
},
@@ -335,38 +387,27 @@ func NewDecryptCmd() *cobra.Command {
335387
return &cmd
336388
}
337389

338-
func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) {
339-
// Use parent command which has the persistent flags.
340-
// This works whether keystore CLI is standalone or embedded as a subcommand.
341-
parent := cmd.Parent()
342-
filePath, err := parent.Flags().GetString("file-path")
343-
if err != nil {
344-
return nil, err
345-
}
346-
dbURL, err := parent.Flags().GetString("db-url")
347-
if err != nil {
348-
return nil, err
349-
}
350-
password, err := parent.Flags().GetString("password")
351-
if err != nil {
352-
return nil, err
353-
}
354-
// Env var fallbacks (flags override)
355-
if filePath == "" {
356-
if v := os.Getenv("KEYSTORE_FILE_PATH"); v != "" {
357-
filePath = v
358-
}
359-
}
360-
if dbURL == "" {
361-
if v := os.Getenv("KEYSTORE_DB_URL"); v != "" {
362-
dbURL = v
363-
}
364-
}
365-
if password == "" {
366-
if v := os.Getenv("KEYSTORE_PASSWORD"); v != "" {
367-
password = v
390+
func loadKeystoreSignerReader(ctx context.Context, cmd *cobra.Command) (interface {
391+
ks.Reader
392+
ks.Signer
393+
}, error) {
394+
// Check if KMS mode is enabled
395+
kmsProfile := os.Getenv("KEYSTORE_KMS_PROFILE")
396+
if kmsProfile != "" {
397+
client, err := kms.NewClient(kmsProfile)
398+
if err != nil {
399+
return nil, fmt.Errorf("create KMS client: %w", err)
368400
}
401+
return kms.NewKeystore(client)
369402
}
403+
return loadKeystore(ctx, cmd)
404+
}
405+
406+
func loadKeystore(ctx context.Context, cmd *cobra.Command) (ks.Keystore, error) {
407+
// Read from environment variables only
408+
filePath := os.Getenv("KEYSTORE_FILE_PATH")
409+
dbURL := os.Getenv("KEYSTORE_DB_URL")
410+
password := os.Getenv("KEYSTORE_PASSWORD")
370411
if password == "" {
371412
return nil, errors.New("keystore password is required")
372413
}

keystore/cli/cli_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ func setupKeystore(t *testing.T) func(t *testing.T) {
2222
require.NoError(t, err)
2323
t.Setenv("KEYSTORE_FILE_PATH", keystoreFile)
2424
t.Setenv("KEYSTORE_PASSWORD", "testpassword")
25+
// Set to empty string to test regular keystore mode.
26+
t.Setenv("KEYSTORE_KMS_PROFILE", "")
2527
return func(t *testing.T) {
2628
f.Close()
2729
os.RemoveAll(tempDir)

keystore/go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/smartcontractkit/chainlink-common/keystore
33
go 1.25.3
44

55
require (
6+
github.com/aws/aws-sdk-go v1.55.5
67
github.com/ethereum/go-ethereum v1.16.2
78
github.com/jmoiron/sqlx v1.4.0
89
github.com/lib/pq v1.10.9
@@ -58,6 +59,7 @@ require (
5859
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
5960
github.com/jackc/pgtype v1.14.4 // indirect
6061
github.com/jackc/pgx/v4 v4.18.3 // indirect
62+
github.com/jmespath/go-jmespath v0.4.0 // indirect
6163
github.com/json-iterator/go v1.1.12 // indirect
6264
github.com/klauspost/compress v1.18.0 // indirect
6365
github.com/klauspost/cpuid/v2 v2.2.10 // indirect

keystore/go.sum

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ github.com/apache/arrow-go/v18 v18.3.1 h1:oYZT8FqONiK74JhlH3WKVv+2NKYoyZ7C2ioD4D
1414
github.com/apache/arrow-go/v18 v18.3.1/go.mod h1:12QBya5JZT6PnBihi5NJTzbACrDGXYkrgjujz3MRQXU=
1515
github.com/apache/thrift v0.21.0 h1:tdPmh/ptjE1IJnhbhrcl2++TauVjy242rkV/UzJChnE=
1616
github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw=
17+
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
18+
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
1719
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
1820
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
1921
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -171,6 +173,10 @@ github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0f
171173
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
172174
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
173175
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
176+
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
177+
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
178+
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
179+
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
174180
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
175181
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
176182
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -505,6 +511,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
505511
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
506512
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
507513
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
514+
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
508515
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
509516
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
510517
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)