Compare commits

..

No commits in common. "4e242c3491f2b78537805f118ec1498a0d9b662e" and "533133486c2d64b36028d7cc6910a5f5dfa80573" have entirely different histories.

16 changed files with 95 additions and 132 deletions

View File

@ -1,8 +1,6 @@
version: "2"
run:
go: "1.24"
tests: false
timeout: 5m
go: "1.22"
linters:
enable:
@ -16,6 +14,7 @@ linters:
- mnd # An analyzer to detect magic numbers
- lll # Reports long lines
- intrange # intrange is a linter to find places where for loops could make use of an integer range
- gofumpt # Gofumpt checks whether code was gofumpt-ed
- gochecknoglobals # Check that no global variables exist
# Default/existing linters that are commonly useful
@ -23,7 +22,11 @@ linters:
- errcheck
- staticcheck
- unused
- gosimple
- ineffassign
- typecheck
- gofmt
- goimports
- misspell
- revive
- gosec
@ -75,14 +78,17 @@ linters-settings:
testifylint:
enable-all: true
usetesting: {}
usetesting:
strict: true
issues:
max-issues-per-linter: 0
max-same-issues: 0
exclude-rules:
# Exclude all linters from running on test files
# Exclude some linters from running on tests files
- path: _test\.go
linters:
- gochecknoglobals
- mnd
- unparam
# Allow long lines in generated code or test data
- path: ".*_gen\\.go"
@ -93,3 +99,6 @@ issues:
- text: "parameter '(args|cmd)' seems to be unused"
linters:
- revive
max-issues-per-linter: 0
max-same-issues: 0

View File

@ -47,7 +47,7 @@ func TestDetermineStateDir(t *testing.T) {
}
// Test with custom config dir
_ = os.Unsetenv(secret.EnvStateDir)
os.Unsetenv(secret.EnvStateDir)
customConfigDir := "/custom-config"
stateDir = secret.DetermineStateDir(customConfigDir)
expectedDir := filepath.Join(customConfigDir, secret.AppID)

View File

@ -74,7 +74,29 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
return fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
if exists {
// Secret exists, get the age secret key from it
var secretValue []byte
if os.Getenv(secret.EnvMnemonic) != "" {
secretValue, err = secretObj.GetValue(nil)
} else {
unlocker, unlockErr := vlt.GetCurrentUnlocker()
if unlockErr != nil {
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
}
secretValue, err = secretObj.GetValue(unlocker)
}
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)
}
ageSecretKey = string(secretValue)
// Validate that it's a valid age secret key
if !isValidAgeSecretKey(ageSecretKey) {
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
}
} else {
// Secret doesn't exist, generate new age key and store it
identity, err := age.GenerateX25519Identity()
if err != nil {
@ -88,19 +110,6 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
if err != nil {
return fmt.Errorf("failed to store age key: %w", err)
}
} else {
// Secret exists, get the age secret key from it
secretValue, err := cli.getSecretValue(vlt, secretObj)
if err != nil {
return fmt.Errorf("failed to get secret value: %w", err)
}
ageSecretKey = string(secretValue)
// Validate that it's a valid age secret key
if !isValidAgeSecretKey(ageSecretKey) {
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
}
}
// Parse the secret key
@ -119,7 +128,7 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer func() { _ = file.Close() }()
defer file.Close()
input = file
}
@ -130,7 +139,7 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
defer file.Close()
output = file
}
@ -205,7 +214,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
if err != nil {
return fmt.Errorf("failed to open input file: %w", err)
}
defer func() { _ = file.Close() }()
defer file.Close()
input = file
}
@ -216,7 +225,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
defer func() { _ = file.Close() }()
defer file.Close()
output = file
}
@ -238,17 +247,3 @@ func isValidAgeSecretKey(key string) bool {
_, err := age.ParseX25519Identity(key)
return err == nil
}
// getSecretValue retrieves the value of a secret using the appropriate unlocker
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) ([]byte, error) {
if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
return secretObj.GetValue(unlocker)
}

View File

@ -11,11 +11,6 @@ import (
"github.com/tyler-smith/go-bip39"
)
const (
defaultSecretLength = 16
mnemonicEntropyBits = 128
)
func newGenerateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "generate",
@ -60,7 +55,7 @@ func newGenerateSecretCmd() *cobra.Command {
},
}
cmd.Flags().IntP("length", "l", defaultSecretLength, "Length of the generated secret (default 16)")
cmd.Flags().IntP("length", "l", 16, "Length of the generated secret (default 16)")
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
@ -70,7 +65,7 @@ func newGenerateSecretCmd() *cobra.Command {
// GenerateMnemonic generates a random BIP39 mnemonic phrase
func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
// Generate 128 bits of entropy for a 12-word mnemonic
entropy, err := bip39.NewEntropy(mnemonicEntropyBits)
entropy, err := bip39.NewEntropy(128)
if err != nil {
return fmt.Errorf("failed to generate entropy: %w", err)
}

View File

@ -80,12 +80,12 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
// Set mnemonic in environment for CreateVault to use
originalMnemonic := os.Getenv(secret.EnvMnemonic)
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
os.Setenv(secret.EnvMnemonic, mnemonicStr)
defer func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
os.Unsetenv(secret.EnvMnemonic)
}
}()

View File

@ -214,7 +214,7 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, filter str
filteredSecrets = secrets
}
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
if jsonOutput {
// For JSON output, get metadata for each secret
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))

View File

@ -20,7 +20,7 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
// Set test environment
for k, v := range env {
_ = os.Setenv(k, v)
os.Setenv(k, v)
}
// Create root command
@ -53,9 +53,9 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
// Restore environment
for k, v := range savedEnv {
if v == "" {
_ = os.Unsetenv(k)
os.Unsetenv(k)
} else {
_ = os.Setenv(k, v)
os.Setenv(k, v)
}
}

View File

@ -98,7 +98,7 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return err
}
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
if jsonOutput {
// Get current vault name for context
currentVault := ""
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {

View File

@ -12,10 +12,6 @@ import (
"github.com/spf13/cobra"
)
const (
tabWriterPadding = 2
)
// newVersionCmd returns the version management command
func newVersionCmd() *cobra.Command {
cli := NewCLIInstance()
@ -45,7 +41,7 @@ func VersionCommands(cli *Instance) *cobra.Command {
Use: "promote <secret-name> <version>",
Short: "Promote a specific version to current",
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
return cli.PromoteVersion(cmd, args[0], args[1])
},
@ -114,8 +110,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
}
// Create table writer
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, tabWriterPadding, ' ', 0)
_, _ = fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "VERSION\tCREATED\tSTATUS\tNOT_BEFORE\tNOT_AFTER")
// Load and display each version's metadata
for _, version := range versions {
@ -129,7 +125,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
if version == currentVersion {
status = "current (error)"
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
continue
}
@ -156,10 +152,10 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
}
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
}
_ = w.Flush()
w.Flush()
return nil
}

View File

@ -13,8 +13,8 @@ import (
)
var (
debugEnabled bool //nolint:gochecknoglobals // Package-wide debug state is necessary
debugLogger *slog.Logger //nolint:gochecknoglobals // Package-wide logger instance is necessary
debugEnabled bool
debugLogger *slog.Logger
)
func init() {

View File

@ -16,10 +16,6 @@ import (
"github.com/spf13/afero"
)
const (
agePrivKeyPassphraseLength = 64
)
// keychainItemNameRegex validates keychain item names
// Allows alphanumeric characters, dots, hyphens, and underscores only
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
@ -257,7 +253,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
// Step 2: Generate a random passphrase for encrypting the age private key
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
agePrivKeyPassphrase, err := generateRandomPassphrase(64)
if err != nil {
return nil, fmt.Errorf("failed to generate age private key passphrase: %w", err)
}

View File

@ -20,11 +20,11 @@ import (
var (
// GPGEncryptFunc is the function used for GPG encryption
// Can be overridden in tests to provide a non-interactive implementation
GPGEncryptFunc = gpgEncryptDefault //nolint:gochecknoglobals // Required for test mocking
GPGEncryptFunc = gpgEncryptDefault
// GPGDecryptFunc is the function used for GPG decryption
// Can be overridden in tests to provide a non-interactive implementation
GPGDecryptFunc = gpgDecryptDefault //nolint:gochecknoglobals // Required for test mocking
GPGDecryptFunc = gpgDecryptDefault
// gpgKeyIDRegex validates GPG key IDs
// Allows either:

View File

@ -286,8 +286,6 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
// getCurrentVaultFunc is a function variable that will be set by the vault package
// to implement the actual GetCurrentVault functionality
//
//nolint:gochecknoglobals // Required to break import cycle
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation

View File

@ -15,11 +15,6 @@ import (
"github.com/spf13/afero"
)
const (
versionNameParts = 2
maxVersionsPerDay = 999
)
// VersionMetadata contains information about a secret version
type VersionMetadata struct {
ID string `json:"id"` // ULID
@ -92,7 +87,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
if entry.IsDir() && strings.HasPrefix(entry.Name(), prefix) {
// Extract serial number
parts := strings.Split(entry.Name(), ".")
if len(parts) == versionNameParts {
if len(parts) == 2 {
var serial int
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err == nil {
if serial > maxSerial {
@ -105,7 +100,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
// Generate new version name
newSerial := maxSerial + 1
if newSerial > maxVersionsPerDay {
if newSerial > 999 {
return "", fmt.Errorf("exceeded maximum versions per day (999)")
}

View File

@ -21,11 +21,10 @@ import (
)
const (
purpose = uint32(83696968) // fixed by BIP-85 ("bip")
vendorID = uint32(592366788) // berlin.sneak
appID = uint32(733482323) // secret
hrp = "age-secret-key-" // Bech32 HRP used by age
x25519KeySize = 32 // 256-bit key size for X25519
purpose = uint32(83696968) // fixed by BIP-85 ("bip")
vendorID = uint32(592366788) // berlin.sneak
appID = uint32(733482323) // secret
hrp = "age-secret-key-" // Bech32 HRP used by age
)
// clamp applies RFC-7748 clamping to a 32-byte scalar.
@ -38,20 +37,16 @@ func clamp(k []byte) {
// IdentityFromEntropy converts 32 deterministic bytes into an
// *age.X25519Identity by round-tripping through Bech32.
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
if len(ent) != x25519KeySize {
if len(ent) != 32 { // 32 bytes = 256-bit key size for X25519
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
}
// Make a copy to avoid modifying the original
key := make([]byte, x25519KeySize)
key := make([]byte, 32) // 32 bytes = 256-bit key size for X25519 // 32 bytes = 256-bit key size for X25519
copy(key, ent)
clamp(key)
const (
bech32BitSize8 = 8 // Standard 8-bit encoding
bech32BitSize5 = 5 // Bech32 5-bit encoding
)
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
data, err := bech32.ConvertBits(key, 8, 5, true) // Convert from 8-bit to 5-bit encoding for bech32
if err != nil {
return nil, fmt.Errorf("bech32 convert: %w", err)
}
@ -86,7 +81,7 @@ func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
drng := bip85.NewBIP85DRNG(entropy)
key := make([]byte, x25519KeySize)
key := make([]byte, 32) // 32 bytes = 256-bit key size for X25519
_, err = drng.Read(key)
if err != nil {
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
@ -115,7 +110,7 @@ func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
drng := bip85.NewBIP85DRNG(entropy)
key := make([]byte, x25519KeySize)
key := make([]byte, 32) // 32 bytes = 256-bit key size for X25519
_, err = drng.Read(key)
if err != nil {
return nil, fmt.Errorf("failed to read from DRNG: %w", err)

View File

@ -40,9 +40,9 @@ const (
// Version bytes for extended keys
var (
// MainNetPrivateKey is the version for mainnet private keys
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4} //nolint:gochecknoglobals // Standard BIP32 constant
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4}
// TestNetPrivateKey is the version for testnet private keys
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94} //nolint:gochecknoglobals // Standard BIP32 constant
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94}
)
// DRNG is a deterministic random number generator seeded by BIP85 entropy
@ -52,15 +52,14 @@ type DRNG struct {
// NewBIP85DRNG creates a new DRNG seeded with BIP85 entropy
func NewBIP85DRNG(entropy []byte) *DRNG {
const bip85EntropySize = 64 // 512 bits
// The entropy must be exactly 64 bytes (512 bits)
if len(entropy) != bip85EntropySize {
if len(entropy) != 64 {
panic("DRNG entropy must be 64 bytes")
}
// Initialize SHAKE256 with the entropy
shake := sha3.NewShake256()
_, _ = shake.Write(entropy) // Write to hash functions never returns an error
shake.Write(entropy)
return &DRNG{
shake: shake,
@ -170,26 +169,17 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
}
// Determine how many bits of entropy to use based on the words
// BIP39 defines specific word counts and their corresponding entropy bits
const (
words12 = 12 // 128 bits of entropy
words15 = 15 // 160 bits of entropy
words18 = 18 // 192 bits of entropy
words21 = 21 // 224 bits of entropy
words24 = 24 // 256 bits of entropy
)
var bits int
switch words {
case words12:
case 12:
bits = 128
case words15:
case 15:
bits = 160
case words18:
case 18:
bits = 192
case words21:
case 21:
bits = 224
case words24:
case 24:
bits = 256
default:
return nil, fmt.Errorf("invalid BIP39 word count: %d", words)
@ -355,30 +345,24 @@ func encodeBase85WithRFC1924Charset(data []byte) string {
// RFC1924 character set
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
const (
base85ChunkSize = 4 // Process 4 bytes at a time
base85DigitCount = 5 // Each chunk produces 5 digits
base85Base = 85 // Base85 encoding uses base 85
)
// Pad data to multiple of 4
padded := make([]byte, ((len(data)+base85ChunkSize-1)/base85ChunkSize)*base85ChunkSize)
padded := make([]byte, ((len(data)+3)/4)*4)
copy(padded, data)
var buf strings.Builder
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize) // Each 4 bytes becomes 5 Base85 characters
buf.Grow(len(padded) * 5 / 4) // Each 4 bytes becomes 5 Base85 characters
// Process in 4-byte chunks
for i := 0; i < len(padded); i += base85ChunkSize {
for i := 0; i < len(padded); i += 4 {
// Convert 4 bytes to uint32 (big-endian)
chunk := binary.BigEndian.Uint32(padded[i : i+base85ChunkSize])
chunk := binary.BigEndian.Uint32(padded[i : i+4])
// Convert to 5 base-85 digits
digits := make([]byte, base85DigitCount)
for j := base85DigitCount - 1; j >= 0; j-- {
idx := chunk % base85Base
digits := make([]byte, 5)
for j := 4; j >= 0; j-- {
idx := chunk % 85
digits[j] = charset[idx]
chunk /= base85Base
chunk /= 85
}
buf.Write(digits)