From 9dffd6ece76c6c07b97ee7cb528cc6b0dd28a553 Mon Sep 17 00:00:00 2001 From: kxue43 Date: Sat, 20 Dec 2025 14:16:46 -0500 Subject: [PATCH 1/3] refactor: New packages `cipher`, `creds`, `terminal`. --- .golangci.yaml | 4 +- auth/cipher.go | 130 -------------------------------- auth/doc.go | 4 - cipher/aes.go | 84 +++++++++++++++++++++ cmd/toolkit-assume-role/main.go | 7 +- {auth => creds}/cache.go | 44 ++++++----- {auth => creds}/command.go | 90 ++++++++++++++++------ {auth => creds}/command_test.go | 38 ++++++---- {auth => terminal}/tty.go | 2 +- 9 files changed, 205 insertions(+), 198 deletions(-) delete mode 100644 auth/cipher.go delete mode 100644 auth/doc.go create mode 100644 cipher/aes.go rename {auth => creds}/cache.go (78%) rename {auth => creds}/command.go (60%) rename {auth => creds}/command_test.go (91%) rename {auth => terminal}/tty.go (98%) diff --git a/.golangci.yaml b/.golangci.yaml index 6b83632..2af87b0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -97,7 +97,7 @@ linters: exclusions: generated: lax rules: - - path: '^auth/cache\.go$' + - path: '^creds/cache\.go$' linters: - gosec # crypto/sha1 is used for hashing, not encryption. @@ -112,7 +112,7 @@ linters: - gosec # local static files server doesn't need timeout text: "G114: Use of net/http serve function that has no support for setting timeouts" - - path: '^auth/command_test\.go$' + - path: '^creds/command_test\.go$' linters: - gosec # unit test only, and we know overflow doesn't happen diff --git a/auth/cipher.go b/auth/cipher.go deleted file mode 100644 index 1e094fa..0000000 --- a/auth/cipher.go +++ /dev/null @@ -1,130 +0,0 @@ -package auth - -import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "io" - - "github.com/zalando/go-keyring" -) - -type ( - KeyFunc func() ([]byte, error) - - Cipher struct { - key []byte - } -) - -const ( - // 32 bytes for AES-256 - keySize = 32 - - service = "kxue43.toolkit.assume-role" - user = "cache-encryption-key" -) - -var ( - ErrCipher = errors.New("cipher failure") - - fromKeyring KeyFunc = keyringGet -) - -func generateKey(size int) (key []byte, encoded string, err error) { - key = make([]byte, size) - - if _, err = io.ReadFull(rand.Reader, key); err != nil { - return nil, "", fmt.Errorf("%w: failed to generate encryption key: %s", ErrCipher, err.Error()) - } - - return key, base64.StdEncoding.EncodeToString(key), nil -} - -func keyringGet() (key []byte, err error) { - var secret string - - secret, err = keyring.Get(service, user) - if err != nil && !errors.Is(err, keyring.ErrNotFound) { - return nil, fmt.Errorf("%w: failed to retrieve encryption key: secret exists but cannot be read: %s", ErrCipher, err.Error()) - } else if errors.Is(err, keyring.ErrNotFound) { - key, secret, err = generateKey(keySize) - if err != nil { - return nil, err - } - - err = keyring.Set(service, user, secret) - if err != nil { - return nil, fmt.Errorf("%w: failed to save newly generated encryption key: %s", ErrCipher, err.Error()) - } - - return key, nil - } - - key, err = base64.StdEncoding.DecodeString(secret) - if err != nil { - return nil, fmt.Errorf("%w: saved encryption key has been corrupted: %s", ErrCipher, err.Error()) - } - - return key, nil -} - -func NewCipher(fn KeyFunc) (Cipher, error) { - key, err := fn() - if err != nil { - return Cipher{}, fmt.Errorf("%w: failed to get encryption key: %s", ErrCipher, err.Error()) - } - - return Cipher{key: key}, nil -} - -func (c Cipher) Encrypt(plaintext []byte) ([]byte, error) { - block, err := aes.NewCipher(c.key) - if err != nil { - return nil, fmt.Errorf("%w: failed to initialize AES block cipher: %s", ErrCipher, err.Error()) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("%w: failed to create GCM: %s", ErrCipher, err.Error()) - } - - // The GCM nonce size is fixed at 12 bytes. - nonce := make([]byte, gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, fmt.Errorf("%w: failed to initialize nonce: %s", ErrCipher, err.Error()) - } - - // The first return value is 'nonce + ciphertext + tag'. - return gcm.Seal(nonce, nonce, plaintext, nil), nil -} - -func (c Cipher) Decrypt(ciphertext []byte) ([]byte, error) { - block, err := aes.NewCipher(c.key) - if err != nil { - return nil, fmt.Errorf("%w: failed to initialize AES block cipher: %s", ErrCipher, err.Error()) - } - - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("%w: failed to create GCM: %s", ErrCipher, err.Error()) - } - - // Extract nonce from the beginning of the ciphertext. - nonceSize := gcm.NonceSize() - if len(ciphertext) < nonceSize { - return nil, fmt.Errorf("%w: ciphertext too short", ErrCipher) - } - - nonce, ciphertextActual := ciphertext[:nonceSize], ciphertext[nonceSize:] - - plaintext, err := gcm.Open(nil, nonce, ciphertextActual, nil) - if err != nil { - return nil, fmt.Errorf("%w: AES-GCM authentication failure, the data have been tampered: %s", ErrCipher, err.Error()) - } - - return plaintext, nil -} diff --git a/auth/doc.go b/auth/doc.go deleted file mode 100644 index 426ef00..0000000 --- a/auth/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package auth implements AWS credential process with caching. -// Cache files are saved on disk and encrypted via AES-GCM with the encryption key stored in the operating system's "native" credentials store. -// For example, Keychain is used on macOS. -package auth diff --git a/cipher/aes.go b/cipher/aes.go new file mode 100644 index 0000000..3554eb0 --- /dev/null +++ b/cipher/aes.go @@ -0,0 +1,84 @@ +package cipher + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "fmt" + "io" +) + +type ( + KeyFunc func(*[32]byte) error + + AesGcm struct { + key [32]byte + } +) + +var ( + ErrCipher = errors.New("cipher failure") +) + +// Non-nil returned error wraps [ErrCipher]. +func NewAesGcm(fn KeyFunc) (*AesGcm, error) { + aes := AesGcm{} + + err := fn(&aes.key) + if err != nil { + return nil, fmt.Errorf("%w: failed to get encryption key: %s", ErrCipher, err.Error()) + } + + return &aes, nil +} + +// Non-nil returned error wraps [ErrCipher]. +func (c *AesGcm) Encrypt(plaintext []byte) ([]byte, error) { + block, err := aes.NewCipher(c.key[:]) + if err != nil { + return nil, fmt.Errorf("%w: failed to initialize AES block cipher: %s", ErrCipher, err.Error()) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("%w: failed to create GCM: %s", ErrCipher, err.Error()) + } + + // The GCM nonce size is fixed at 12 bytes. + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("%w: failed to initialize nonce: %s", ErrCipher, err.Error()) + } + + // The first return value is 'nonce + ciphertext + tag'. + return gcm.Seal(nonce, nonce, plaintext, nil), nil +} + +// Non-nil returned error wraps [ErrCipher]. +func (c *AesGcm) Decrypt(ciphertext []byte) ([]byte, error) { + block, err := aes.NewCipher(c.key[:]) + if err != nil { + return nil, fmt.Errorf("%w: failed to initialize AES block cipher: %s", ErrCipher, err.Error()) + } + + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("%w: failed to create GCM: %s", ErrCipher, err.Error()) + } + + // Extract nonce from the beginning of the ciphertext. + nonceSize := gcm.NonceSize() + if len(ciphertext) < nonceSize { + return nil, fmt.Errorf("%w: ciphertext too short", ErrCipher) + } + + nonce, ciphertextActual := ciphertext[:nonceSize], ciphertext[nonceSize:] + + plaintext, err := gcm.Open(nil, nonce, ciphertextActual, nil) + if err != nil { + return nil, fmt.Errorf("%w: AES-GCM authentication failure, the data have been tampered: %s", ErrCipher, err.Error()) + } + + return plaintext, nil +} diff --git a/cmd/toolkit-assume-role/main.go b/cmd/toolkit-assume-role/main.go index 76edb4f..674f185 100644 --- a/cmd/toolkit-assume-role/main.go +++ b/cmd/toolkit-assume-role/main.go @@ -8,11 +8,12 @@ import ( "github.com/aws/aws-sdk-go-v2/config" - "github.com/kxue43/cli-toolkit/auth" + "github.com/kxue43/cli-toolkit/creds" + "github.com/kxue43/cli-toolkit/terminal" ) var ( - cmd = auth.AssumeRoleCmd{} + cmd = creds.AssumeRoleCmd{} helpMsg = `Usage: %s -mfa-serial=STRING -profile=STRING [flags] @@ -57,7 +58,7 @@ func main() { defer func() { _ = ttyDevice.Close() }() - tty := auth.NewTTY(ttyDevice, "toolkit-assume-role: ", 0) + tty := terminal.NewTTY(ttyDevice, "toolkit-assume-role: ", 0) defer func() { if tty.FlushLogs() != nil { exitCode = 1 diff --git a/auth/cache.go b/creds/cache.go similarity index 78% rename from auth/cache.go rename to creds/cache.go index 0fba71d..b29def0 100644 --- a/auth/cache.go +++ b/creds/cache.go @@ -1,4 +1,4 @@ -package auth +package creds import ( "crypto/sha1" @@ -12,6 +12,8 @@ import ( "sort" "strconv" "time" + + "github.com/kxue43/cli-toolkit/cipher" ) type ( @@ -23,10 +25,10 @@ type ( Version int `json:"Version"` } - Cacher struct { - logger Logger + cacher struct { + logger logger + cipher *cipher.AesGcm cacheDir string - cipher Cipher } cacheFile struct { @@ -55,18 +57,18 @@ func (cs cacheFileSlice) Swap(i, j int) { cs[i], cs[j] = cs[j], cs[i] } -func GetPrefix(s string) string { +func getPrefix(s string) string { h := sha1.Sum([]byte(s)) return hex.EncodeToString(h[:])[0:7] } -func EncodeToFileName(roleArn string, ts time.Time) string { - return fmt.Sprintf("%s-%s", GetPrefix(roleArn), strconv.FormatInt(ts.Unix(), 10)) +func encodeToFileName(roleArn string, ts time.Time) string { + return fmt.Sprintf("%s-%s", getPrefix(roleArn), strconv.FormatInt(ts.Unix(), 10)) } -func DecodeFromFileName(roleArn, fileName string) (ts time.Time, err error) { - regex := regexp.MustCompile(fmt.Sprintf(`^%s-(\d+)$`, GetPrefix(roleArn))) +func decodeFromFileName(roleArn, fileName string) (ts time.Time, err error) { + regex := regexp.MustCompile(fmt.Sprintf(`^%s-(\d+)$`, getPrefix(roleArn))) matches := regex.FindStringSubmatch(fileName) if matches == nil { @@ -84,7 +86,12 @@ func DecodeFromFileName(roleArn, fileName string) (ts time.Time, err error) { } // Non-nil returned error wraps [ErrCacheInit]. -func NewCacher(logger Logger, cipher Cipher) (*Cacher, error) { +func newCacher(logger logger, fn cipher.KeyFunc) (*cacher, error) { + aes, err := cipher.NewAesGcm(fn) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrCacheInit, err.Error()) + } + home, err := os.UserHomeDir() if err != nil { return nil, fmt.Errorf("%w: could not locate user home directory", ErrCacheInit) @@ -98,7 +105,7 @@ func NewCacher(logger Logger, cipher Cipher) (*Cacher, error) { return nil, fmt.Errorf("%w: failed to create cache directory", ErrCacheInit) } - return &Cacher{logger: logger, cacheDir: cacheDir, cipher: cipher}, nil + return &cacher{logger: logger, cacheDir: cacheDir, cipher: aes}, nil } else if err != nil { return nil, fmt.Errorf("%w: failed to locate cache directory: %s", ErrCacheInit, err.Error()) } @@ -107,11 +114,12 @@ func NewCacher(logger Logger, cipher Cipher) (*Cacher, error) { return nil, fmt.Errorf("%w: cache directory is already a file", ErrCacheInit) } - return &Cacher{logger: logger, cacheDir: cacheDir, cipher: cipher}, nil + return &cacher{logger: logger, cacheDir: cacheDir, cipher: aes}, nil } // Non-nil returned error wraps [ErrInvalidCredential] or [ErrCacheSave]. -func (c *Cacher) Save(roleArn string, output *CredentialProcessOutput) (contents []byte, err error) { +// contents is valid for use as long as it's not nil. +func (c *cacher) Save(roleArn string, output *CredentialProcessOutput) (contents []byte, err error) { ts, err := time.Parse(time.RFC3339, output.Expiration) if err != nil { return nil, fmt.Errorf("%w: expiration %q is not of the right format: %s", ErrInvalidCredential, output.Expiration, err.Error()) @@ -122,7 +130,7 @@ func (c *Cacher) Save(roleArn string, output *CredentialProcessOutput) (contents return nil, fmt.Errorf("%w: failed to serialize CredentialProcessOutput: %s", ErrInvalidCredential, err.Error()) } - filePath := filepath.Join(c.cacheDir, EncodeToFileName(roleArn, ts)) + filePath := filepath.Join(c.cacheDir, encodeToFileName(roleArn, ts)) encrypted, err := c.cipher.Encrypt(contents) if err != nil { @@ -138,10 +146,10 @@ func (c *Cacher) Save(roleArn string, output *CredentialProcessOutput) (contents // Retrieve tries to retrieve AWS credentials from cache files. // It succeeded if and only if the returned byte slice is not nil. -func (c *Cacher) Retrieve(roleArn string) (contents []byte) { +func (c *cacher) Retrieve(roleArn string) (contents []byte) { max := time.Now().Add(time.Minute * 10) actives := make(cacheFileSlice, 0) - pattern := filepath.Join(c.cacheDir, fmt.Sprintf(`%s-*`, GetPrefix(roleArn))) + pattern := filepath.Join(c.cacheDir, fmt.Sprintf(`%s-*`, getPrefix(roleArn))) cacheFiles, err := filepath.Glob(pattern) if err != nil { @@ -153,7 +161,7 @@ func (c *Cacher) Retrieve(roleArn string) (contents []byte) { var expiration time.Time for _, fullPath := range cacheFiles { - if expiration, err = DecodeFromFileName(roleArn, filepath.Base(fullPath)); err != nil { + if expiration, err = decodeFromFileName(roleArn, filepath.Base(fullPath)); err != nil { c.deleteCacheFile(fullPath, "invalid") continue @@ -193,7 +201,7 @@ func (c *Cacher) Retrieve(roleArn string) (contents []byte) { return contents } -func (c *Cacher) deleteCacheFile(fullPath string, desc string) { +func (c *cacher) deleteCacheFile(fullPath string, desc string) { if os.Remove(fullPath) != nil { c.logger.Printf("Failed to delete %s cache file %q.\n", desc, fullPath) } diff --git a/auth/command.go b/creds/command.go similarity index 60% rename from auth/command.go rename to creds/command.go index e6eaf17..9568645 100644 --- a/auth/command.go +++ b/creds/command.go @@ -1,8 +1,13 @@ -package auth +// Package creds implements AWS credential process with caching. +// Cache files are saved on disk and encrypted via AES-GCM with the encryption key stored in the operating system's "native" credentials store. +// For example, Keychain is used on macOS. +package creds import ( "bytes" "context" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -12,18 +17,22 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/credentials/stscreds" "github.com/aws/aws-sdk-go-v2/service/sts" + "github.com/zalando/go-keyring" + + "github.com/kxue43/cli-toolkit/cipher" + "github.com/kxue43/cli-toolkit/terminal" ) type ( - Logger interface { + logger interface { Printf(string, ...any) Print(...any) } AssumeRoleCmd struct { - logger Logger - prompter Prompter - cacher *Cacher + logger logger + prompter prompter + cacher *cacher client *sts.Client RoleArn string MFASerial string @@ -33,16 +42,59 @@ type ( DurationSeconds int64 } - Prompter struct { + prompter struct { io.ReadWriter } ) +const ( + service = "kxue43.toolkit.assume-role" + user = "cache-encryption-key" +) + var ( ErrInvalidInput = errors.New("invalid CLI input") + + fromKeyring cipher.KeyFunc = keyringGet ) -func (c Prompter) MFAToken() (code string, err error) { +func generateKey(key *[32]byte) (string, error) { + if _, err := io.ReadFull(rand.Reader, key[:]); err != nil { + return "", fmt.Errorf("failed to generate encryption key: %s", err.Error()) + } + + return base64.StdEncoding.EncodeToString(key[:]), nil +} + +func keyringGet(key *[32]byte) error { + secret, err := keyring.Get(service, user) + if err != nil && !errors.Is(err, keyring.ErrNotFound) { + return fmt.Errorf("failed to retrieve encryption key: secret exists but cannot be read: %s", err.Error()) + } else if errors.Is(err, keyring.ErrNotFound) { + secret, err = generateKey(key) + if err != nil { + return err + } + + err = keyring.Set(service, user, secret) + if err != nil { + return fmt.Errorf("failed to save newly generated encryption key: %s", err.Error()) + } + + return nil + } + + decoded, err := base64.StdEncoding.DecodeString(secret) + if err != nil || len(decoded) != len(key) { + return fmt.Errorf("saved encryption key has been corrupted: %s", err.Error()) + } + + copy(key[:], decoded) + + return nil +} + +func (c prompter) MFAToken() (code string, err error) { _, err = io.WriteString(c, "MFA code: ") if err != nil { return "", fmt.Errorf("failed to prompt for MFA code: %w", err) @@ -82,19 +134,14 @@ func (a *AssumeRoleCmd) ValidateInputs(args []string) error { } // Non-nil returned error wraps [ErrCacheInit]. -func (a *AssumeRoleCmd) Init(tty *TTY, cfg aws.Config) error { - a.prompter = Prompter{ReadWriter: tty} +func (a *AssumeRoleCmd) Init(tty *terminal.TTY, cfg aws.Config) (err error) { + a.prompter = prompter{ReadWriter: tty} a.logger = tty a.client = sts.NewFromConfig(cfg) - cipher, err := NewCipher(fromKeyring) - if err != nil { - return fmt.Errorf("%w: failed to create cache cipher: %s", ErrCacheInit, err.Error()) - } - - a.cacher, err = NewCacher(a.logger, cipher) + a.cacher, err = newCacher(a.logger, fromKeyring) return err } @@ -144,18 +191,13 @@ func (a *AssumeRoleCmd) Run(ctx context.Context, dest io.Writer) (err error) { } else if err != nil { a.logger.Print(err.Error()) } + } - _, err = dest.Write(output) + if output == nil { + output, err = json.Marshal(&soutput) if err != nil { - return fmt.Errorf("failed to write credentials to destination: %s", err.Error()) + return fmt.Errorf("failed to marshal credential process output: %s", err.Error()) } - - return nil - } - - output, err = json.Marshal(&soutput) - if err != nil { - return fmt.Errorf("failed to marshal credential process output: %s", err.Error()) } _, err = dest.Write(output) diff --git a/auth/command_test.go b/creds/command_test.go similarity index 91% rename from auth/command_test.go rename to creds/command_test.go index 9973eee..57dc023 100644 --- a/auth/command_test.go +++ b/creds/command_test.go @@ -1,4 +1,4 @@ -package auth +package creds import ( "bytes" @@ -16,10 +16,12 @@ import ( "github.com/awsdocs/aws-doc-sdk-examples/gov2/testtools" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/kxue43/cli-toolkit/terminal" ) type ( - MockFileDescriptor struct { + MockTerminal struct { r bytes.Buffer w bytes.Buffer } @@ -30,11 +32,11 @@ type ( } ) -func (fd *MockFileDescriptor) Read(p []byte) (n int, err error) { +func (fd *MockTerminal) Read(p []byte) (n int, err error) { return fd.r.Read(p) } -func (fd *MockFileDescriptor) Write(p []byte) (n int, err error) { +func (fd *MockTerminal) Write(p []byte) (n int, err error) { return fd.w.Write(p) } @@ -64,13 +66,17 @@ func (m *HomeDirMocker) TearDown(t *testing.T) { } func TestAssumeRoleCmdRun(t *testing.T) { - aesKey, _, err := generateKey(keySize) + var aesKey [32]byte + + _, err := generateKey(&aesKey) require.NoError(t, err, "should be able to generate a random encryption key") - fromKeyring = func() ([]byte, error) { + fromKeyring = func(key *[32]byte) error { t.Helper() - return aesKey, nil + copy(key[:], aesKey[:]) + + return nil } defer func() { fromKeyring = keyringGet }() @@ -99,14 +105,14 @@ func TestAssumeRoleCmdRun(t *testing.T) { token := "123456" - mockedTtyDevice := &MockFileDescriptor{} + mockedTerminal := &MockTerminal{} - _, err := mockedTtyDevice.r.WriteString(token + "\n") + _, err := mockedTerminal.r.WriteString(token + "\n") require.NoError(t, err, "should be able to write token to mocked TTY file descriptor") - tty := NewTTY(mockedTtyDevice, "toolkit-assume-role: ", 0) + tty := terminal.NewTTY(mockedTerminal, "toolkit-assume-role: ", 0) - dest := MockFileDescriptor{} + dest := MockTerminal{} soutput := CredentialProcessOutput{ AccessKeyId: "access-key-id", @@ -159,7 +165,7 @@ func TestAssumeRoleCmdRun(t *testing.T) { err = cmd.Run(ctx, &dest) require.NoError(t, err, "should be able to run command without error") - cacheFilePath := filepath.Join(hdm.TempDir, ".aws", "toolkit-cache", EncodeToFileName(roleArn, expiration)) + cacheFilePath := filepath.Join(hdm.TempDir, ".aws", "toolkit-cache", encodeToFileName(roleArn, expiration)) info, err := os.Stat(cacheFilePath) require.NoError(t, err, "should be able to locate the cache file created by the Run method") @@ -199,11 +205,11 @@ func TestAssumeRoleCmdRun(t *testing.T) { DurationSeconds: int64(duration), } - mockedTtyDevice := &MockFileDescriptor{} + mockedTerminal := &MockTerminal{} - tty := NewTTY(mockedTtyDevice, "toolkit-assume-role: ", 0) + tty := terminal.NewTTY(mockedTerminal, "toolkit-assume-role: ", 0) - dest := MockFileDescriptor{} + dest := MockTerminal{} ctx := context.Background() @@ -225,7 +231,7 @@ func TestAssumeRoleCmdRun(t *testing.T) { err = cmd.Run(ctx, &dest) require.NoError(t, err, "should be able to run command without error") - cacheFilePath := filepath.Join(hdm.TempDir, ".aws", "toolkit-cache", EncodeToFileName(roleArn, expiration)) + cacheFilePath := filepath.Join(hdm.TempDir, ".aws", "toolkit-cache", encodeToFileName(roleArn, expiration)) info, err := os.Stat(cacheFilePath) require.NoError(t, err, "should be able to locate the cache file created by the Run method") diff --git a/auth/tty.go b/terminal/tty.go similarity index 98% rename from auth/tty.go rename to terminal/tty.go index 9f791eb..1ba7944 100644 --- a/auth/tty.go +++ b/terminal/tty.go @@ -1,4 +1,4 @@ -package auth +package terminal import ( "bytes" From 7ffe660aef85af23565087f20dbb030106bf8bb2 Mon Sep 17 00:00:00 2001 From: kxue43 Date: Sat, 20 Dec 2025 14:23:08 -0500 Subject: [PATCH 2/3] refactor: `cipher` package more like a library. --- cipher/aes.go | 10 +++++++--- creds/cache.go | 2 +- creds/command.go | 6 +++--- creds/command_test.go | 5 +++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cipher/aes.go b/cipher/aes.go index 3554eb0..1cc5c77 100644 --- a/cipher/aes.go +++ b/cipher/aes.go @@ -10,19 +10,23 @@ import ( ) type ( - KeyFunc func(*[32]byte) error + AesKeyFunc func(*[AesKeySize]byte) error AesGcm struct { - key [32]byte + key [AesKeySize]byte } ) +const ( + AesKeySize = 32 +) + var ( ErrCipher = errors.New("cipher failure") ) // Non-nil returned error wraps [ErrCipher]. -func NewAesGcm(fn KeyFunc) (*AesGcm, error) { +func NewAesGcm(fn AesKeyFunc) (*AesGcm, error) { aes := AesGcm{} err := fn(&aes.key) diff --git a/creds/cache.go b/creds/cache.go index b29def0..30bb7d3 100644 --- a/creds/cache.go +++ b/creds/cache.go @@ -86,7 +86,7 @@ func decodeFromFileName(roleArn, fileName string) (ts time.Time, err error) { } // Non-nil returned error wraps [ErrCacheInit]. -func newCacher(logger logger, fn cipher.KeyFunc) (*cacher, error) { +func newCacher(logger logger, fn cipher.AesKeyFunc) (*cacher, error) { aes, err := cipher.NewAesGcm(fn) if err != nil { return nil, fmt.Errorf("%w: %s", ErrCacheInit, err.Error()) diff --git a/creds/command.go b/creds/command.go index 9568645..7e61988 100644 --- a/creds/command.go +++ b/creds/command.go @@ -55,10 +55,10 @@ const ( var ( ErrInvalidInput = errors.New("invalid CLI input") - fromKeyring cipher.KeyFunc = keyringGet + fromKeyring cipher.AesKeyFunc = keyringGet ) -func generateKey(key *[32]byte) (string, error) { +func generateKey(key *[cipher.AesKeySize]byte) (string, error) { if _, err := io.ReadFull(rand.Reader, key[:]); err != nil { return "", fmt.Errorf("failed to generate encryption key: %s", err.Error()) } @@ -66,7 +66,7 @@ func generateKey(key *[32]byte) (string, error) { return base64.StdEncoding.EncodeToString(key[:]), nil } -func keyringGet(key *[32]byte) error { +func keyringGet(key *[cipher.AesKeySize]byte) error { secret, err := keyring.Get(service, user) if err != nil && !errors.Is(err, keyring.ErrNotFound) { return fmt.Errorf("failed to retrieve encryption key: secret exists but cannot be read: %s", err.Error()) diff --git a/creds/command_test.go b/creds/command_test.go index 57dc023..42763e6 100644 --- a/creds/command_test.go +++ b/creds/command_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/kxue43/cli-toolkit/cipher" "github.com/kxue43/cli-toolkit/terminal" ) @@ -66,12 +67,12 @@ func (m *HomeDirMocker) TearDown(t *testing.T) { } func TestAssumeRoleCmdRun(t *testing.T) { - var aesKey [32]byte + var aesKey [cipher.AesKeySize]byte _, err := generateKey(&aesKey) require.NoError(t, err, "should be able to generate a random encryption key") - fromKeyring = func(key *[32]byte) error { + fromKeyring = func(key *[cipher.AesKeySize]byte) error { t.Helper() copy(key[:], aesKey[:]) From 074d57dfdc1bf02a59c687cf0e335a10623c8b73 Mon Sep 17 00:00:00 2001 From: kxue43 Date: Sat, 20 Dec 2025 14:28:21 -0500 Subject: [PATCH 3/3] refactor: `ttyDevice` -> `device`. --- cmd/toolkit-assume-role/main.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/toolkit-assume-role/main.go b/cmd/toolkit-assume-role/main.go index 674f185..36dca7f 100644 --- a/cmd/toolkit-assume-role/main.go +++ b/cmd/toolkit-assume-role/main.go @@ -49,16 +49,16 @@ func main() { flag.Parse() - ttyDevice, err := os.OpenFile("/dev/tty", os.O_RDWR|os.O_SYNC, 0600) + device, err := os.OpenFile("/dev/tty", os.O_RDWR|os.O_SYNC, 0600) if err != nil { exitCode = 1 return } - defer func() { _ = ttyDevice.Close() }() + defer func() { _ = device.Close() }() - tty := terminal.NewTTY(ttyDevice, "toolkit-assume-role: ", 0) + tty := terminal.NewTTY(device, "toolkit-assume-role: ", 0) defer func() { if tty.FlushLogs() != nil { exitCode = 1