Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test-and-lint.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ jobs:
- name: Lint with golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: "v2.6.1"
version: "v2.7.2"
6 changes: 5 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ 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$'
linters:
- gosec
# unit test only, and we know overflow doesn't happen
text: "G115: integer overflow conversion int64 -> int32"
paths:
- adhoc/

Expand All @@ -128,4 +133,3 @@ formatters:
- standard
- default
- localmodule

2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/golangci/golangci-lint
rev: v2.6.1
rev: v2.7.2
hooks:
- id: golangci-lint-fmt
- id: golangci-lint-full
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ Various CLI programs that aid my own workflows.
```

- `toolkit-show-md` takes a Markdown file, converts it to a GitHub style HTML and displays the HTML in user's default browser.
It's used by [kxue43/showmd-vim-plugin](https://github.com/kxue43/showmd-vim-plugin).

```bash
go install github.com/kxue43/cli-toolkit/cmd/toolkit-show-md@latest
Expand Down
86 changes: 51 additions & 35 deletions auth/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,10 @@ type (
Version int `json:"Version"`
}

cacheSaveRetriever struct {
Logger *log.Logger
Cacher struct {
logger *log.Logger
cacheDir string
cipher Cipher
}

cacheFile struct {
Expand All @@ -37,9 +38,11 @@ type (
cacheFileSlice []*cacheFile
)

const expirationLayout = time.RFC3339

var ErrInvalidCredential = errors.New("invalid AWS credential")
var (
ErrCacheInit = errors.New("cache initialization failure")
ErrCacheSave = errors.New("failed to save cache file")
ErrInvalidCredential = errors.New("invalid AWS credential")
)

func (cs cacheFileSlice) Len() int {
return len(cs)
Expand All @@ -60,11 +63,11 @@ func GetPrefix(s string) string {
}

func EncodeToFileName(roleArn string, ts time.Time) string {
return fmt.Sprintf("%s-%s.json", GetPrefix(roleArn), strconv.FormatInt(ts.Unix(), 10))
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+)\.json$`, GetPrefix(roleArn)))
regex := regexp.MustCompile(fmt.Sprintf(`^%s-(\d+)$`, GetPrefix(roleArn)))

matches := regex.FindStringSubmatch(fileName)
if matches == nil {
Expand All @@ -81,65 +84,69 @@ func DecodeFromFileName(roleArn, fileName string) (ts time.Time, err error) {
return ts, nil
}

func NewCacheSaveRetriever(logger *log.Logger) (*cacheSaveRetriever, error) {
// Non-nil returned error wraps [ErrCacheInit].
func NewCacher(logger *log.Logger, cipher Cipher) (*Cacher, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, errors.New("could not locate user home directory")
return nil, fmt.Errorf("%w: could not locate user home directory", ErrCacheInit)
}

cacheDir := filepath.Join(home, ".aws", "toolkit-cache")

info, err := os.Stat(cacheDir)
if os.IsNotExist(err) {
if err = os.MkdirAll(cacheDir, 0750); err != nil {
return nil, errors.New("failed to create cache directory")
return nil, fmt.Errorf("%w: failed to create cache directory", ErrCacheInit)
}

return &cacheSaveRetriever{Logger: logger, cacheDir: cacheDir}, nil
return &Cacher{logger: logger, cacheDir: cacheDir, cipher: cipher}, nil
} else if err != nil {
return nil, err
return nil, fmt.Errorf("%w: failed to locate cache directory: %s", ErrCacheInit, err.Error())
}

if !info.IsDir() {
return nil, errors.New("cache directory is already a file")
return nil, fmt.Errorf("%w: cache directory is already a file", ErrCacheInit)
}

return &cacheSaveRetriever{Logger: logger, cacheDir: cacheDir}, nil
return &Cacher{logger: logger, cacheDir: cacheDir, cipher: cipher}, nil
}

// Save marshals output and saves it to a file whose name is generated according to roleArn and the current timestamp.
// On success, it returns the binary contents saved to the file as a byte slice. On failure, if the returned
// error wraps ErrInvalidCredential, the AWS credential is invalid. Otherwise only the write operation failed
// but the AWS credential is valid.
func (c *cacheSaveRetriever) Save(roleArn string, output *CredentialProcessOutput) (contents []byte, err error) {
ts, err := time.Parse(expirationLayout, output.Expiration)
// Non-nil returned error wraps [ErrInvalidCredential] or [ErrCacheSave].
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: %w", ErrInvalidCredential, output.Expiration, err)
return nil, fmt.Errorf("%w: expiration %q is not of the right format: %s", ErrInvalidCredential, output.Expiration, err.Error())
}

fileName := EncodeToFileName(roleArn, ts)
filePath := filepath.Join(c.cacheDir, fileName)

contents, err = json.Marshal(output)
if err != nil {
return nil, fmt.Errorf("%w: failed to serialize CredentialProcessOutput: %w", ErrInvalidCredential, err)
return nil, fmt.Errorf("%w: failed to serialize CredentialProcessOutput: %s", ErrInvalidCredential, err.Error())
}

filePath := filepath.Join(c.cacheDir, EncodeToFileName(roleArn, ts))

encrypted, err := c.cipher.Encrypt(contents)
if err != nil {
return contents, fmt.Errorf("%w: failed to encrypt before saving: %s", ErrCacheSave, err.Error())
}

if err = os.WriteFile(filePath, contents, 0600); err != nil {
return contents, fmt.Errorf("failed to save credentials to cache file: %w", err)
if err = os.WriteFile(filePath, encrypted, 0600); err != nil {
return contents, fmt.Errorf("%w: failed to write to disk: %s", ErrCacheSave, err.Error())
}

return contents, nil
}

func (c *cacheSaveRetriever) Retrieve(roleArn string) (contents []byte, err error) {
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-*.json`, GetPrefix(roleArn)))
pattern := filepath.Join(c.cacheDir, fmt.Sprintf(`%s-*`, GetPrefix(roleArn)))

cacheFiles, err := filepath.Glob(pattern)
if err != nil {
return nil, fmt.Errorf("invalid file globbing pattern: %w", err)
c.logger.Printf("invalid file globbing pattern: %s\n", err)

return nil
}

var expiration time.Time
Expand All @@ -159,7 +166,7 @@ func (c *cacheSaveRetriever) Retrieve(roleArn string) (contents []byte, err erro
}

if len(actives) == 0 {
return nil, nil
return nil
}

sort.Sort(actives)
Expand All @@ -170,14 +177,23 @@ func (c *cacheSaveRetriever) Retrieve(roleArn string) (contents []byte, err erro

contents, err = os.ReadFile(actives[0].filePath)
if err != nil {
return nil, fmt.Errorf("failed to read active cache file %q: %w", actives[0].filePath, err)
c.logger.Printf("failed to read active cache file %q: %s\n", actives[0].filePath, err)

return nil
}

return contents, nil
contents, err = c.cipher.Decrypt(contents)
if err != nil {
c.logger.Printf("failed to decrypt cache file %q: %s\n", actives[0].filePath, err)

return nil
}

return contents
}

func (c *cacheSaveRetriever) 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)
c.logger.Printf("Failed to delete %s cache file %q.\n", desc, fullPath)
}
}
130 changes: 130 additions & 0 deletions auth/cipher.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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
}
Loading