secrets

package
v0.13.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 30, 2026 License: Apache-2.0 Imports: 23 Imported by: 0

README

secrets

Package secrets provides secure secret storage operations using AES-256-GCM encryption with OS keychain integration for master key management.

Overview

The package uses a hybrid approach for secret storage:

  • Master encryption key is stored in the OS keychain (or environment variable fallback)
  • Secrets are encrypted with AES-256-GCM and stored as individual files

This allows for storing large secrets (e.g., authentication tokens up to ~100KB) that wouldn't fit in the OS keychain directly, while still leveraging secure key storage.

Installation

import "github.com/oakwood-commons/scafctl/pkg/secrets"

Quick Start

package main

import (
    "context"
    "log"

    "github.com/oakwood-commons/scafctl/pkg/secrets"
)

func main() {
    // Create a new store with default options
    store, err := secrets.New()
    if err != nil {
        log.Fatal(err)
    }

    ctx := context.Background()

    // Store a secret
    err = store.Set(ctx, "my-api-key", []byte("sk-1234567890abcdef"))
    if err != nil {
        log.Fatal(err)
    }

    // Retrieve a secret
    value, err := store.Get(ctx, "my-api-key")
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Secret: %s", value)

    // List all secrets
    names, err := store.List(ctx)
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Secrets: %v", names)

    // Check if a secret exists
    exists, err := store.Exists(ctx, "my-api-key")
    if err != nil {
        log.Fatal(err)
    }
    log.Printf("Exists: %v", exists)

    // Delete a secret
    err = store.Delete(ctx, "my-api-key")
    if err != nil {
        log.Fatal(err)
    }
}

Configuration Options

Custom Secrets Directory
store, err := secrets.New(
    secrets.WithSecretsDir("/custom/path/to/secrets"),
)
Custom Keyring (for testing)
mockKeyring := secrets.NewMockKeyring()
store, err := secrets.New(
    secrets.WithKeyring(mockKeyring),
)
With Logger
import "github.com/go-logr/zapr"

logger := zapr.NewLogger(zapLogger)
store, err := secrets.New(
    secrets.WithLogger(logger),
)

Storage Locations

Platform Default Secrets Directory
Linux ~/.local/share/scafctl/secrets/ (or $XDG_DATA_HOME/scafctl/secrets/)
macOS ~/.local/share/scafctl/secrets/
Windows %LOCALAPPDATA%\scafctl\secrets\

Override with the SCAFCTL_SECRETS_DIR environment variable.

Secret Name Rules

  • Allowed characters: a-z, A-Z, 0-9, -, _, .
  • Length: 1-255 characters
  • Cannot start with . or -
  • Cannot contain ..
  • Case-sensitive

Valid examples: my-api-key, AWS_SECRET_KEY, config.prod.v2

Invalid examples: .hidden, -invalid, my..secret

Encryption Details

Master Key
  • Algorithm: 256-bit random key (32 bytes)
  • Storage: OS keychain (service: scafctl, account: master-key)
  • Fallback: SCAFCTL_SECRET_KEY environment variable (base64-encoded)
Secret Files
  • Algorithm: AES-256-GCM (authenticated encryption)
  • File format: [version:1 byte][nonce:12 bytes][ciphertext+tag:N bytes]
  • File extension: .enc
  • Permissions: Files 0600, Directory 0700

Error Handling

import "errors"

value, err := store.Get(ctx, "my-secret")
if err != nil {
    switch {
    case errors.Is(err, secrets.ErrNotFound):
        // Secret doesn't exist
    case errors.Is(err, secrets.ErrInvalidName):
        // Invalid secret name
    case errors.Is(err, secrets.ErrCorrupted):
        // Secret file is corrupted (auto-deleted)
    case errors.Is(err, secrets.ErrKeyringAccess):
        // Cannot access OS keychain
    default:
        // Other error (filesystem, etc.)
    }
}

Testing

Using MockStore
func TestMyFunction(t *testing.T) {
    store := secrets.NewMockStore()
    
    // Pre-populate data
    store.Data["api-key"] = []byte("test-value")
    
    // Inject errors
    store.GetErr = errors.New("simulated error")
    
    // Test your code
    result, err := myFunction(store)
    
    // Verify calls
    assert.Equal(t, []string{"api-key"}, store.GetCalls)
}
Using MockKeyring
func TestWithCustomKeyring(t *testing.T) {
    keyring := secrets.NewMockKeyring()
    
    store, err := secrets.New(
        secrets.WithSecretsDir(t.TempDir()),
        secrets.WithKeyring(keyring),
    )
    require.NoError(t, err)
    
    // Test your code
}

CI/CD Considerations

In CI environments where OS keychain access is unavailable:

  1. Set SCAFCTL_SECRET_KEY environment variable with a base64-encoded 32-byte key:

    export SCAFCTL_SECRET_KEY=$(openssl rand -base64 32)
    
  2. The store will automatically fall back to using this key.

Thread Safety

All Store operations are thread-safe and can be called concurrently from multiple goroutines.

Benchmarks

Run benchmarks with:

go test -bench=. -benchmem ./pkg/secrets/...

Example results:

BenchmarkEncrypt/100B-10          500000    2345 ns/op   42.65 MB/s
BenchmarkEncrypt/1KB-10           200000    5678 ns/op  180.23 MB/s
BenchmarkEncrypt/100KB-10          10000  123456 ns/op  810.12 MB/s
BenchmarkDecrypt/100B-10          500000    1234 ns/op   81.04 MB/s
BenchmarkDecrypt/1KB-10           200000    4567 ns/op  224.53 MB/s

API Reference

Store Interface
type Store interface {
    Get(ctx context.Context, name string) ([]byte, error)
    Set(ctx context.Context, name string, value []byte) error
    Delete(ctx context.Context, name string) error
    List(ctx context.Context) ([]string, error)
    Exists(ctx context.Context, name string) (bool, error)
}
Constructor
func New(opts ...Option) (Store, error)
Options
func WithSecretsDir(dir string) Option
func WithKeyring(kr Keyring) Option
func WithLogger(logger logr.Logger) Option
Errors
var (
    ErrNotFound      = errors.New("secret not found")
    ErrInvalidName   = errors.New("invalid secret name")
    ErrCorrupted     = errors.New("secret is corrupted")
    ErrKeyringAccess = errors.New("cannot access keyring")
)

Documentation

Overview

Package secrets provides secure secret storage operations using AES-256-GCM encryption with OS keychain integration for master key management.

The package uses a hybrid approach for secret storage:

  • Master encryption key is stored in the OS keychain (or env var fallback)
  • Secrets are encrypted and stored as individual files

This allows for storing large secrets (e.g., auth tokens) that wouldn't fit in the OS keychain directly, while still leveraging secure key storage.

Basic usage:

store, err := secrets.New()
if err != nil {
    log.Fatal(err)
}

// Store a secret
err = store.Set(ctx, "my-api-key", []byte("secret-value"))

// Retrieve a secret
value, err := store.Get(ctx, "my-api-key")

// List all secrets
names, err := store.List(ctx)

// Delete a secret
err = store.Delete(ctx, "my-api-key")

Index

Constants

View Source
const (

	// KeyringBackendOS indicates the OS keyring was used.
	KeyringBackendOS = "os"

	// KeyringBackendEnv indicates the environment variable keyring was used.
	KeyringBackendEnv = "env"

	// KeyringBackendFile indicates the file-based keyring was used.
	KeyringBackendFile = "file"
)
View Source
const (
	// KeyringService is the service name used in the OS keyring.
	KeyringService = "scafctl"

	// KeyringMasterKeyAccount is the account name for the master encryption key.
	KeyringMasterKeyAccount = "master-key"

	// EnvSecretKey is the environment variable name for the fallback secret key.
	EnvSecretKey = "SCAFCTL_SECRET_KEY" //nolint:gosec // This is the env var name, not a credential
)
View Source
const (
	// MinNameLength is the minimum length for a secret name.
	MinNameLength = 1
	// MaxNameLength is the maximum length for a secret name.
	MaxNameLength = 255
)
View Source
const (
	// InternalSecretPrefix is the default prefix used for internal secrets (e.g. auth tokens).
	InternalSecretPrefix = "scafctl." //nolint:gosec // Not a credential, just a naming prefix
)

Variables

View Source
var (
	// ErrNotFound is returned when a secret does not exist.
	ErrNotFound = errors.New("secret not found")

	// ErrInvalidName is returned when a secret name is invalid.
	ErrInvalidName = errors.New("invalid secret name")

	// ErrCorrupted is returned when a secret file is corrupted and cannot be decrypted.
	ErrCorrupted = errors.New("secret is corrupted")

	// ErrKeyringAccess is returned when the OS keyring cannot be accessed.
	ErrKeyringAccess = errors.New("cannot access keyring")
)

Sentinel errors for the secrets package.

View Source
var ErrKeyNotFound = errors.New("key not found in keyring")

ErrKeyNotFound is returned when a key is not found in the keyring.

Functions

func DeleteMasterKeyFromKeyring

func DeleteMasterKeyFromKeyring(kr Keyring) error

DeleteMasterKeyFromKeyring removes the master encryption key from the keyring.

func Export added in v0.6.0

func Export(exportData *secretcrypto.ExportFormat, opts ExportOptions) ([]byte, error)

Export serializes a secret collection to bytes in the specified format, optionally encrypting the output.

func FilterSecrets added in v0.5.0

func FilterSecrets(names []string, includeInternal bool) []string

FilterSecrets filters secret names, optionally including internal secrets. When includeInternal is false, internal secrets are excluded using the default prefix.

func FilterSecretsFor added in v0.7.0

func FilterSecretsFor(names []string, includeInternal bool, prefix string) []string

FilterSecretsFor filters secret names using the given internal-secret prefix.

func FilterUserSecrets added in v0.5.0

func FilterUserSecrets(names []string) []string

FilterUserSecrets filters out internal secrets from a list using the default prefix.

func FilterUserSecretsFor added in v0.7.0

func FilterUserSecretsFor(names []string, prefix string) []string

FilterUserSecretsFor filters out secrets matching the given prefix.

func GetMasterKeyFromKeyring

func GetMasterKeyFromKeyring(kr Keyring) ([]byte, error)

GetMasterKeyFromKeyring retrieves the master encryption key from the keyring. The key is stored as base64-encoded string in the keyring.

func InternalSecretPrefixFor added in v0.7.0

func InternalSecretPrefixFor(binaryName string) string

InternalSecretPrefixFor returns the internal-secret prefix for the given binary name. Falls back to the default prefix when binaryName is empty.

func IsEncrypted added in v0.6.0

func IsEncrypted(data []byte) bool

IsEncrypted returns true if the data starts with the encrypted header marker.

func IsInternalSecret added in v0.5.0

func IsInternalSecret(name string) bool

IsInternalSecret returns true if the secret name belongs to the default internal namespace.

func IsInternalSecretFor added in v0.7.0

func IsInternalSecretFor(name, prefix string) bool

IsInternalSecretFor returns true if the secret name starts with the given prefix.

func IsValidName

func IsValidName(name string) bool

IsValidName returns true if the secret name is valid.

func SecretType added in v0.5.0

func SecretType(name string) string

SecretType returns "internal" or "user" based on the secret name prefix.

func SetMasterKeyInKeyring

func SetMasterKeyInKeyring(kr Keyring, key []byte) error

SetMasterKeyInKeyring stores the master encryption key in the keyring. The key is stored as base64-encoded string.

func ValidateName

func ValidateName(name string) error

ValidateName checks if a secret name is valid according to the naming rules. Returns nil if valid, or an InvalidNameError with details if invalid.

func ValidateSecretName added in v0.5.0

func ValidateSecretName(name string, includeInternal bool) error

ValidateSecretName validates a secret name using the default internal prefix.

func ValidateSecretNameFor added in v0.7.0

func ValidateSecretNameFor(name string, includeInternal bool, prefix string) error

ValidateSecretNameFor validates a secret name, optionally allowing internal secrets identified by the given prefix.

func ValidateUserSecretName added in v0.5.0

func ValidateUserSecretName(name string) error

ValidateUserSecretName validates a secret name for user operations. Returns an error if the name is invalid or uses the reserved internal prefix.

Types

type CorruptedSecretError

type CorruptedSecretError struct {
	Name   string `json:"name" yaml:"name" doc:"The name of the corrupted secret" example:"my-secret"`
	Reason string `json:"reason" yaml:"reason" doc:"The reason the secret is corrupted" example:"invalid version byte"`
	Cause  error  `json:"-" yaml:"-" doc:"The underlying error"`
}

CorruptedSecretError provides details about a corrupted secret.

func NewCorruptedSecretError

func NewCorruptedSecretError(name, reason string, cause error) *CorruptedSecretError

NewCorruptedSecretError creates a new CorruptedSecretError with the given name, reason, and cause.

func (*CorruptedSecretError) Error

func (e *CorruptedSecretError) Error() string

Error implements the error interface.

func (*CorruptedSecretError) Is

func (e *CorruptedSecretError) Is(target error) bool

Is reports whether the target error is ErrCorrupted.

func (*CorruptedSecretError) Unwrap

func (e *CorruptedSecretError) Unwrap() error

Unwrap returns the underlying cause for use with errors.Is and errors.As.

type ExportOptions added in v0.6.0

type ExportOptions struct {
	// Format is the output format: "json" or "yaml" (default: "yaml").
	Format string
	// Encrypt enables password-based encryption of the output.
	Encrypt bool
	// Password for encryption. Required when Encrypt is true.
	Password string `json:"-"` // nosec G117 -- not serialized, used only as function parameter
}

ExportOptions configures secret export behavior.

type ImportOptions added in v0.6.0

type ImportOptions struct {
	// Password for decrypting encrypted imports. Required when data is encrypted.
	Password string `json:"-"` // nosec G117 -- not serialized, used only as function parameter
}

ImportOptions configures secret import behavior.

type ImportResult added in v0.6.0

type ImportResult struct {
	// Secrets contains the user secrets from the import (internal secrets filtered out).
	Secrets []secretcrypto.ExportedSecret
	// SkippedInternal is the number of internal secrets that were filtered out.
	SkippedInternal int
	// Version is the version string from the import file.
	Version string
	// VersionMismatch is true if the import version differs from the current export version.
	VersionMismatch bool
}

ImportResult holds the result of parsing an import file.

func Import added in v0.6.0

func Import(data []byte, opts ImportOptions) (*ImportResult, error)

Import reads secrets from raw bytes, auto-detecting format (encrypted vs plaintext, JSON vs YAML), filtering out internal secrets, and returning a structured result.

type InvalidNameError

type InvalidNameError struct {
	Name   string `json:"name" yaml:"name" doc:"The invalid secret name" example:".invalid-name"`
	Reason string `json:"reason" yaml:"reason" doc:"The reason the name is invalid" example:"cannot start with '.'"`
}

InvalidNameError provides details about an invalid secret name.

func NewInvalidNameError

func NewInvalidNameError(name, reason string) *InvalidNameError

NewInvalidNameError creates a new InvalidNameError with the given name and reason.

func (*InvalidNameError) Error

func (e *InvalidNameError) Error() string

Error implements the error interface.

func (*InvalidNameError) Is

func (e *InvalidNameError) Is(target error) bool

Is reports whether the target error is ErrInvalidName.

type Keyring

type Keyring interface {
	// Get retrieves a value from the keyring.
	// Returns an error if the key does not exist or cannot be accessed.
	Get(service, account string) (string, error)

	// Set stores a value in the keyring.
	// Creates or updates the existing value.
	Set(service, account, value string) error

	// Delete removes a value from the keyring.
	// Returns an error if the key does not exist or cannot be deleted.
	Delete(service, account string) error
}

Keyring defines the interface for keyring operations. This interface abstracts the OS keychain to allow for testing and alternative implementations.

func NewDefaultKeyring

func NewDefaultKeyring() Keyring

NewDefaultKeyring creates the default keyring with the following fallback order:

  1. OS keyring (most secure - uses OS keychain)
  2. Environment variable SCAFCTL_SECRET_KEY (explicit user intent, e.g. CI)
  3. File-based key storage (auto-fallback, less secure but "just works")

type KeyringCall

type KeyringCall struct {
	Service string
	Account string
}

KeyringCall records a call to Get or Delete.

type KeyringError

type KeyringError struct {
	Operation string `json:"operation" yaml:"operation" doc:"The keyring operation that failed" example:"get"`
	Cause     error  `json:"-" yaml:"-" doc:"The underlying error"`
}

KeyringError wraps a keyring access error with additional context.

func NewKeyringError

func NewKeyringError(operation string, cause error) *KeyringError

NewKeyringError creates a new KeyringError with the given operation and cause.

func (*KeyringError) Error

func (e *KeyringError) Error() string

Error implements the error interface.

func (*KeyringError) Is

func (e *KeyringError) Is(target error) bool

Is reports whether the target error is ErrKeyringAccess.

func (*KeyringError) Unwrap

func (e *KeyringError) Unwrap() error

Unwrap returns the underlying cause for use with errors.Is and errors.As.

type KeyringSetCall

type KeyringSetCall struct {
	Service string
	Account string
	Value   string
}

KeyringSetCall records a call to Set.

type MockKeyring

type MockKeyring struct {

	// Data holds the mock keyring data
	Data map[string]string

	// Error injection fields
	GetErr    error
	SetErr    error
	DeleteErr error

	// Call tracking
	GetCalls    []KeyringCall
	SetCalls    []KeyringSetCall
	DeleteCalls []KeyringCall
	// contains filtered or unexported fields
}

MockKeyring is a mock implementation of the Keyring interface for testing. It provides configurable behavior including error injection.

func NewMockKeyring

func NewMockKeyring() *MockKeyring

NewMockKeyring creates a new MockKeyring with an empty data map.

func (*MockKeyring) Delete

func (m *MockKeyring) Delete(service, account string) error

Delete removes a value from the mock keyring.

func (*MockKeyring) Get

func (m *MockKeyring) Get(service, account string) (string, error)

Get retrieves a value from the mock keyring.

func (*MockKeyring) Reset

func (m *MockKeyring) Reset()

Reset clears all data and call tracking.

func (*MockKeyring) Set

func (m *MockKeyring) Set(service, account, value string) error

Set stores a value in the mock keyring.

type MockStore

type MockStore struct {

	// Data holds the mock secret data
	Data map[string][]byte

	// Error injection fields - set these to make operations return errors
	GetErr    error
	SetErr    error
	DeleteErr error
	ListErr   error
	ExistsErr error
	RotateErr error

	// Call tracking
	GetCalls    []string
	SetCalls    []SetCall
	DeleteCalls []string
	ListCalls   int
	ExistsCalls []string
	RotateCalls int
	// contains filtered or unexported fields
}

MockStore is a mock implementation of the Store interface for testing. It provides configurable behavior including error injection and call tracking.

func NewMockStore

func NewMockStore() *MockStore

NewMockStore creates a new MockStore with an empty data map.

func (*MockStore) Delete

func (m *MockStore) Delete(_ context.Context, name string) error

Delete removes a secret from the mock store.

func (*MockStore) Exists

func (m *MockStore) Exists(_ context.Context, name string) (bool, error)

Exists checks if a secret exists in the mock store.

func (*MockStore) Get

func (m *MockStore) Get(_ context.Context, name string) ([]byte, error)

Get retrieves a secret by name from the mock store.

func (*MockStore) KeyringBackend added in v0.2.0

func (m *MockStore) KeyringBackend() string

KeyringBackend returns an empty string for mock stores.

func (*MockStore) List

func (m *MockStore) List(_ context.Context) ([]string, error)

List returns all secret names in the mock store.

func (*MockStore) Reset

func (m *MockStore) Reset()

Reset clears all data and call tracking.

func (*MockStore) Rotate

func (m *MockStore) Rotate(_ context.Context) error

Rotate simulates master key rotation by not changing anything in the mock. It only tracks that rotation was called.

func (*MockStore) Set

func (m *MockStore) Set(_ context.Context, name string, value []byte) error

Set stores a secret in the mock store.

type OSKeyring

type OSKeyring struct{}

OSKeyring implements Keyring using the OS keychain via go-keyring.

func NewOSKeyring

func NewOSKeyring() *OSKeyring

NewOSKeyring creates a new OS keyring wrapper.

func (*OSKeyring) Delete

func (k *OSKeyring) Delete(service, account string) error

Delete removes a value from the OS keyring.

func (*OSKeyring) Get

func (k *OSKeyring) Get(service, account string) (string, error)

Get retrieves a value from the OS keyring.

func (*OSKeyring) Set

func (k *OSKeyring) Set(service, account, value string) error

Set stores a value in the OS keyring.

type Option

type Option func(*config)

Option configures the Store.

func WithKeyring

func WithKeyring(kr Keyring) Option

WithKeyring sets a custom keyring implementation. This is primarily useful for testing or for environments where the OS keyring is not available.

func WithLogger

func WithLogger(logger logr.Logger) Option

WithLogger sets the logger for the Store. If not set, a discard logger is used.

func WithRequireSecureKeyring added in v0.6.0

func WithRequireSecureKeyring(require bool) Option

WithRequireSecureKeyring makes store initialization fail if the OS keyring is unavailable and scafctl would have to fall back to an insecure file-based or environment-variable-based master key. Enable this in production or shared environments to prevent silent degradation of secret protection.

func WithSecretsDir

func WithSecretsDir(dir string) Option

WithSecretsDir overrides the default secrets directory. If empty, the XDG-compliant default will be used:

  • Linux: ~/.local/share/scafctl/secrets/ (XDG_DATA_HOME)
  • macOS: ~/.local/share/scafctl/secrets/
  • Windows: %LOCALAPPDATA%\scafctl\secrets\

This can also be overridden by the SCAFCTL_SECRETS_DIR environment variable.

type SetCall

type SetCall struct {
	Name  string
	Value []byte
}

SetCall records a call to Set with name and value.

type Store

type Store interface {
	// Get retrieves a secret by name.
	// Returns ErrNotFound if the secret does not exist.
	// Returns ErrCorrupted if the secret file is corrupted and cannot be decrypted.
	// Returns ErrInvalidName if the name is invalid.
	Get(ctx context.Context, name string) ([]byte, error)

	// Set stores a secret. Creates or overwrites existing.
	// Returns ErrInvalidName if the name is invalid.
	Set(ctx context.Context, name string, value []byte) error

	// Delete removes a secret.
	// No error is returned if the secret does not exist.
	// Returns ErrInvalidName if the name is invalid.
	Delete(ctx context.Context, name string) error

	// List returns the names of all stored secrets.
	List(ctx context.Context) ([]string, error)

	// Exists checks if a secret exists.
	// Returns ErrInvalidName if the name is invalid.
	Exists(ctx context.Context, name string) (bool, error)

	// Rotate rotates the master encryption key by re-encrypting all secrets.
	// This operation is atomic - if any step fails, all secrets are preserved
	// with the original key. The new key is generated, all secrets are
	// re-encrypted, and the keyring is updated.
	Rotate(ctx context.Context) error

	// KeyringBackend returns the identifier of the keyring backend used for
	// master key storage. Possible values: "os", "env", "file", or "" if unknown.
	KeyringBackend() string
}

Store provides secure secret storage operations. All operations are safe for concurrent use.

func New

func New(opts ...Option) (Store, error)

New creates a new Store with the given options. If no keyring is provided, the default keyring (OS keychain with env var fallback) is used. If no secrets directory is provided, the platform-specific default is used.

The master encryption key is retrieved from the keyring on initialization. If no master key exists:

  • If there are existing secrets, they are deleted (orphaned) and a new key is generated
  • If there are no existing secrets, a new key is generated

Options:

  • WithSecretsDir: Override the secrets directory
  • WithKeyring: Provide a custom keyring implementation
  • WithLogger: Set a logger for diagnostic output

Directories

Path Synopsis
Package secretcrypto provides password-based encryption and decryption for secret export/import operations using PBKDF2 key derivation and AES-256-GCM.
Package secretcrypto provides password-based encryption and decryption for secret export/import operations using PBKDF2 key derivation and AES-256-GCM.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL