Update golangci-lint to v2.12.2 with canonical config (#29)
check / check (push) Successful in 43s
check / check (push) Successful in 43s
Bumps golangci-lint from v2.1.6 (digest-only pin in the `Dockerfile` lint stage) to v2.12.2, pinned by tag and digest (Debian-based image). Replaces `.golangci.yml` with the canonical strict config: all linters enabled except the standard disable list (`exhaustruct`, `depguard`, `godot`, `wsl`, `wrapcheck`, `varnamelen`), `lll` at 88, `funlen` 80/50, `cyclop` 15, `dupl` 100, and test files are now linted (the old config had `tests: false`, an enable-only list of ~20 linters, `lll` 120, and a blanket exclusion of `internal/macse`). The stricter config surfaced ~1550 findings, all fixed: - `wsl_v5` (439) / `nlreturn` (24): blank-line insertions - `lll` (309): line wrapping at 88 columns; long literals split with `+` concatenation, values unchanged - `noinlineerr` (130): `if err := ...` split into assignment plus check - `paralleltest` (116): `t.Parallel()` added to tests without shared state; reasoned `//nolint` where `t.Setenv` or shared fixtures forbid it - `err113` (97): package-level sentinel errors (new `internal/vault/errors.go`), `%w` wrapping, `errors.Is` - `perfsprint` (74) / `modernize` (39) / `intrange`: `strconv`, `errors.New`, `slices.Contains`, `any`, `SplitSeq` - `goconst` (40) / `dupword` (41) / `testifylint` (42) / `thelper` (33): constants, assertion fixes, `t.Helper()` - `noctx` (22): `exec.CommandContext` for gpg/CLI invocations - `testpackage` (18): black-box tests moved to `_test` packages where they use only exported identifiers; white-box files carry a reasoned `//nolint` - `funlen`/`cyclop`/`gocognit`/`nestif`/`dupl`: behavior-preserving helper extraction - assorted singletons: `gosec`, `gosmopolitan`, `funcorder`, `nonamedreturns`, `makezero`, `prealloc`, `godox`, `nolintlint`, `ireturn`, `nilnil`, `gochecknoinits` ## User-visible strings **None changed.** Every error message this branch composes is byte-identical to the one `main` composes. The `err113` sentinels are shaped so `fmt.Errorf` reassembles the original text around them: the sentinel carries the fixed words and the caller supplies the interpolated value in the position it has always occupied. Where the value sits mid-sentence the sentinel holds only a fragment (e.g. `vault.ErrVaultNotFound` is `"does not exist"`, composed by its caller as `vault <name> does not exist`); each such sentinel documents the message it participates in. Verified mechanically, not by inspection: every `fmt.Errorf` and `errors.New` call site in both trees is parsed, the `Error()` text of any sentinel passed to `%w` is substituted in, and the resulting sets of composed message templates are compared. All 350 templates `main` produces are still produced, character for character. The set of lost or altered messages is empty. ## `unlocker list` `findUnlockerIDByMetadata` returns `(string, error)` rather than signalling failure with an empty ID, so an unreadable `unlockers.d` is no longer indistinguishable from "no matching entry". `UnlockersList` skips such an entry with a warning naming the directory — its behavior before the scan was extracted into a helper — instead of emitting a row under a synthesized fallback ID that no `unlocker remove` or `unlocker select` can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by `internal/cli/unlockers_list_test.go`. `TODO.md` records the change plus follow-ups (version-completion TODOs formerly in code comments, darwin-gated files exceeding 88 columns that Linux CI does not lint). `make check` is green and the pinned v2.12.2 image reports `0 issues.` Note the test suite needs the memlock ulimit from `script/cibuild` for the 10MB memguard test; that requirement is pre-existing. Not changed: `script/bootstrap` installs golangci-lint via the system package manager (no version pin to bump), and `script/lint` invokes whatever `golangci-lint` is on PATH. golangci-lint v2.12 deprecates `gomodguard` in favor of `gomodguard_v2` (warning only); the canonical config owns that decision. Co-authored-by: sneak <sneak@sneak.berlin> Reviewed-on: #29 Co-authored-by: clawbot <clawbot@noreply.example.org> Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #29.
This commit is contained in:
@@ -12,7 +12,8 @@ const (
|
||||
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" //nolint:gosec // G101: This is an env var name, not a credential
|
||||
//nolint:gosec // G101: env var name, not a credential
|
||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
)
|
||||
|
||||
+67
-31
@@ -2,6 +2,7 @@ package secret
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -12,39 +13,61 @@ import (
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var (
|
||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||
errStdinNotTerminal = errors.New(
|
||||
"cannot read passphrase from non-terminal stdin " +
|
||||
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||
"environment variable or run interactively")
|
||||
errStderrNotTerminal = errors.New(
|
||||
"cannot prompt for passphrase: stderr is not a terminal " +
|
||||
"(running in non-interactive mode). Please set the " +
|
||||
"SB_UNLOCK_PASSPHRASE environment variable")
|
||||
errEmptyPassphrase = errors.New("passphrase cannot be empty")
|
||||
)
|
||||
|
||||
// EncryptToRecipient encrypts data to a recipient using age
|
||||
// The data parameter should be a LockedBuffer for secure memory handling
|
||||
func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([]byte, error) {
|
||||
func EncryptToRecipient(
|
||||
data *memguard.LockedBuffer, recipient age.Recipient,
|
||||
) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
Debug("Creating age encryptor")
|
||||
|
||||
w, err := age.Encrypt(&buf, recipient)
|
||||
if err != nil {
|
||||
Debug("Failed to create encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to create encryptor: %w", err)
|
||||
}
|
||||
Debug("Created age encryptor successfully")
|
||||
|
||||
Debug("Created age encryptor successfully")
|
||||
Debug("Writing data to encryptor")
|
||||
if _, err := w.Write(data.Bytes()); err != nil {
|
||||
|
||||
_, err = w.Write(data.Bytes())
|
||||
if err != nil {
|
||||
Debug("Failed to write data to encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
|
||||
Debug("Wrote data to encryptor successfully")
|
||||
Debug("Closing encryptor")
|
||||
if err := w.Close(); err != nil {
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
Debug("Failed to close encryptor", "error", err)
|
||||
|
||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||
}
|
||||
|
||||
Debug("Closed encryptor successfully")
|
||||
|
||||
result := buf.Bytes()
|
||||
@@ -54,7 +77,9 @@ func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([
|
||||
}
|
||||
|
||||
// DecryptWithIdentity decrypts data with an identity using age
|
||||
func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBuffer, error) {
|
||||
func DecryptWithIdentity(
|
||||
data []byte, identity age.Identity,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
||||
@@ -68,7 +93,8 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu
|
||||
// Create a secure buffer for the decrypted data
|
||||
resultBuffer := memguard.NewBufferFromBytes(result)
|
||||
|
||||
// Zero out the original slice to prevent plaintext from lingering in unprotected memory
|
||||
// Zero out the original slice to prevent plaintext from lingering
|
||||
// in unprotected memory
|
||||
for i := range result {
|
||||
result[i] = 0
|
||||
}
|
||||
@@ -76,17 +102,22 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu
|
||||
return resultBuffer, nil
|
||||
}
|
||||
|
||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
||||
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
||||
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
||||
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
||||
// scrypt-based encryption. Both data and passphrase parameters should
|
||||
// be LockedBuffers for secure memory handling
|
||||
func EncryptWithPassphrase(
|
||||
data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
|
||||
) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
}
|
||||
if passphrase == nil {
|
||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
|
||||
// Create recipient directly from passphrase - unavoidable string conversion due to age API
|
||||
if passphrase == nil {
|
||||
return nil, errNilPassphraseBuffer
|
||||
}
|
||||
|
||||
// Create recipient directly from passphrase - unavoidable string
|
||||
// conversion due to age API
|
||||
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
||||
@@ -95,14 +126,18 @@ func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.Loc
|
||||
return EncryptToRecipient(data, recipient)
|
||||
}
|
||||
|
||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
||||
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
||||
// DecryptWithPassphrase decrypts data using a passphrase with age's
|
||||
// scrypt-based decryption. The passphrase parameter should be a
|
||||
// LockedBuffer for secure memory handling
|
||||
func DecryptWithPassphrase(
|
||||
encryptedData []byte, passphrase *memguard.LockedBuffer,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
if passphrase == nil {
|
||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
||||
return nil, errNilPassphraseBuffer
|
||||
}
|
||||
|
||||
// Create identity directly from passphrase - unavoidable string conversion due to age API
|
||||
// Create identity directly from passphrase - unavoidable string
|
||||
// conversion due to age API
|
||||
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
||||
@@ -117,29 +152,30 @@ func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuff
|
||||
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(syscall.Stdin) {
|
||||
// Not a terminal - never read passphrases from piped input for security reasons
|
||||
return nil, fmt.Errorf("cannot read passphrase from non-terminal stdin " +
|
||||
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||
"environment variable or run interactively")
|
||||
// Not a terminal - never read passphrases from piped input
|
||||
// for security reasons
|
||||
return nil, errStdinNotTerminal
|
||||
}
|
||||
|
||||
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
|
||||
// stdin is a terminal, check if stderr is also a terminal for
|
||||
// interactive prompting
|
||||
if !term.IsTerminal(syscall.Stderr) {
|
||||
return nil, fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal " +
|
||||
"(running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE " +
|
||||
"environment variable")
|
||||
return nil, errStderrNotTerminal
|
||||
}
|
||||
|
||||
// Both stdin and stderr are terminals - use secure password reading
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
|
||||
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
|
||||
|
||||
// Print newline to stderr since ReadPassword doesn't echo
|
||||
fmt.Fprintln(os.Stderr)
|
||||
|
||||
if len(passphrase) == 0 {
|
||||
return nil, fmt.Errorf("passphrase cannot be empty")
|
||||
return nil, errEmptyPassphrase
|
||||
}
|
||||
|
||||
// Create a secure buffer and copy the passphrase
|
||||
|
||||
+29
-13
@@ -13,28 +13,33 @@ 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 //nolint:gochecknoglobals // package debug state
|
||||
debugLogger *slog.Logger //nolint:gochecknoglobals // package debug logger
|
||||
)
|
||||
|
||||
//nolint:gochecknoinits // debug logging must be ready before any package use
|
||||
func init() {
|
||||
InitDebugLogging()
|
||||
}
|
||||
|
||||
// InitDebugLogging initializes the debug logging system based on current GODEBUG environment variable
|
||||
// InitDebugLogging initializes the debug logging system based on the
|
||||
// current GODEBUG environment variable
|
||||
func InitDebugLogging() {
|
||||
godebug := os.Getenv("GODEBUG")
|
||||
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
||||
|
||||
if !debugEnabled {
|
||||
// Create a no-op logger that discards all output
|
||||
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
debugLogger = slog.New(slog.DiscardHandler)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Disable stderr buffering for immediate debug output when debugging is enabled
|
||||
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
// Disable stderr buffering for immediate debug output when
|
||||
// debugging is enabled
|
||||
//nolint:dogsled // syscall.Syscall returns three values, none needed
|
||||
_, _, _ = syscall.Syscall(
|
||||
syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
||||
|
||||
// Check if STDERR is a TTY
|
||||
isTTY := term.IsTerminal(syscall.Stderr)
|
||||
@@ -58,14 +63,19 @@ func IsDebugEnabled() bool {
|
||||
return debugEnabled
|
||||
}
|
||||
|
||||
// Warn logs a warning message to stderr unconditionally (visible without --verbose or debug flags)
|
||||
// Warn logs a warning message to stderr unconditionally (visible
|
||||
// without --verbose or debug flags)
|
||||
func Warn(msg string, args ...any) {
|
||||
output := fmt.Sprintf("WARNING: %s", msg)
|
||||
var output strings.Builder
|
||||
|
||||
output.WriteString("WARNING: " + msg)
|
||||
|
||||
for i := 0; i+1 < len(args); i += 2 {
|
||||
output += fmt.Sprintf(" %s=%v", args[i], args[i+1])
|
||||
fmt.Fprintf(&output, " %s=%v", args[i], args[i+1])
|
||||
}
|
||||
output += "\n"
|
||||
fmt.Fprint(os.Stderr, output)
|
||||
|
||||
output.WriteString("\n")
|
||||
fmt.Fprint(os.Stderr, output.String())
|
||||
}
|
||||
|
||||
// Debug logs a debug message with optional attributes
|
||||
@@ -73,14 +83,16 @@ func Debug(msg string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(msg, args...)
|
||||
}
|
||||
|
||||
// DebugF logs a formatted debug message with optional attributes
|
||||
func DebugF(format string, args ...any) {
|
||||
// Debugf logs a formatted debug message with optional attributes
|
||||
func Debugf(format string, args ...any) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
@@ -89,6 +101,7 @@ func DebugWith(msg string, attrs ...slog.Attr) {
|
||||
if !debugEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
|
||||
}
|
||||
|
||||
@@ -118,15 +131,18 @@ func (h *colorizedHandler) Handle(_ context.Context, record slog.Record) error {
|
||||
if record.NumAttrs() > 0 {
|
||||
output += " \033[33m{"
|
||||
first := true
|
||||
|
||||
record.Attrs(func(attr slog.Attr) bool {
|
||||
if !first {
|
||||
output += ", "
|
||||
}
|
||||
|
||||
first = false
|
||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
output += "}\033[0m"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box test of unexported debug internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -90,9 +91,11 @@ func TestDebugLogging(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // exercises process-global debug logger state
|
||||
func TestDebugFunctions(t *testing.T) {
|
||||
// Enable debug for testing
|
||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||
|
||||
defer InitDebugLogging() // Re-initialize after test
|
||||
|
||||
InitDebugLogging()
|
||||
@@ -107,8 +110,8 @@ func TestDebugFunctions(t *testing.T) {
|
||||
Debug("test with args", "key", "value", "number", 42)
|
||||
})
|
||||
|
||||
t.Run("DebugF", func(_ *testing.T) {
|
||||
DebugF("formatted message: %s %d", "test", 123)
|
||||
t.Run("Debugf", func(_ *testing.T) {
|
||||
Debugf("formatted message: %s %d", "test", 123)
|
||||
})
|
||||
|
||||
t.Run("DebugWith", func(_ *testing.T) {
|
||||
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// DetermineStateDir determines the state directory based on environment variables and OS.
|
||||
// DetermineStateDir determines the state directory based on environment
|
||||
// variables and OS.
|
||||
// It returns an error if no usable directory can be determined.
|
||||
func DetermineStateDir(customConfigDir string) (string, error) {
|
||||
// Check for environment variable first
|
||||
@@ -28,11 +29,14 @@ func DetermineStateDir(customConfigDir string) (string, error) {
|
||||
// Fallback to a reasonable default if we can't determine user config dir
|
||||
homeDir, homeErr := os.UserHomeDir()
|
||||
if homeErr != nil {
|
||||
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
|
||||
return "", fmt.Errorf(
|
||||
"unable to determine state directory: config dir: %w, home dir: %w",
|
||||
err, homeErr)
|
||||
}
|
||||
|
||||
fallbackDir := filepath.Join(homeDir, ".config", AppID)
|
||||
Warn("Could not determine user config directory, falling back to default", "fallback", fallbackDir, "error", err)
|
||||
Warn("Could not determine user config directory, falling back to default",
|
||||
"fallback", fallbackDir, "error", err)
|
||||
|
||||
return fallbackDir, nil
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package secret
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
)
|
||||
|
||||
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
@@ -9,11 +11,11 @@ func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
// On Darwin, os.UserHomeDir may still succeed via the password
|
||||
// database, so we also test via an explicit empty-customConfigDir
|
||||
// path to exercise the fallback branch.
|
||||
t.Setenv(EnvStateDir, "")
|
||||
t.Setenv(secret.EnvStateDir, "")
|
||||
t.Setenv("HOME", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
result, err := DetermineStateDir("")
|
||||
result, err := secret.DetermineStateDir("")
|
||||
// On systems where both lookups fail, we must get an error.
|
||||
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
||||
// result should still be valid (non-empty, not root-relative).
|
||||
@@ -21,29 +23,36 @@ func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
||||
// Good — the error case is handled.
|
||||
return
|
||||
}
|
||||
if result == "/.config/"+AppID || result == "" {
|
||||
t.Errorf("DetermineStateDir returned dangerous/empty path %q without error", result)
|
||||
|
||||
if result == "/.config/"+secret.AppID || result == "" {
|
||||
t.Errorf(
|
||||
"DetermineStateDir returned dangerous/empty path %q without error",
|
||||
result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
||||
t.Setenv(EnvStateDir, "/custom/state")
|
||||
result, err := DetermineStateDir("")
|
||||
t.Setenv(secret.EnvStateDir, "/custom/state")
|
||||
|
||||
result, err := secret.DetermineStateDir("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result != "/custom/state" {
|
||||
t.Errorf("expected /custom/state, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
||||
t.Setenv(EnvStateDir, "")
|
||||
result, err := DetermineStateDir("/my/config")
|
||||
t.Setenv(secret.EnvStateDir, "")
|
||||
|
||||
result, err := secret.DetermineStateDir("/my/config")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
expected := "/my/config/" + AppID
|
||||
|
||||
expected := "/my/config/" + secret.AppID
|
||||
if result != expected {
|
||||
t.Errorf("expected %q, got %q", expected, result)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -14,6 +13,7 @@ import (
|
||||
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||
type KeychainUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
KeychainItemName string `json:"keychainItemName"`
|
||||
}
|
||||
|
||||
@@ -24,7 +24,21 @@ type KeychainUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
var errKeychainNotSupported = fmt.Errorf("keychain unlockers are only supported on macOS")
|
||||
var errKeychainNotSupported = errors.New(
|
||||
"keychain unlockers are only supported on macOS")
|
||||
|
||||
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin
|
||||
// platforms. The returned instance's methods that require macOS
|
||||
// functionality will return errors.
|
||||
func NewKeychainUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *KeychainUnlocker {
|
||||
return &KeychainUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms
|
||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
@@ -48,7 +62,7 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
||||
|
||||
// GetID returns the unlocker ID
|
||||
func (k *KeychainUnlocker) GetID() string {
|
||||
return fmt.Sprintf("%s-keychain", k.Metadata.CreatedAt.Format("2006-01-02.15.04"))
|
||||
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain"
|
||||
}
|
||||
|
||||
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||
@@ -61,22 +75,14 @@ func (k *KeychainUnlocker) Remove() error {
|
||||
return errKeychainNotSupported
|
||||
}
|
||||
|
||||
// NewKeychainUnlocker creates a stub KeychainUnlocker on non-Darwin platforms.
|
||||
// The returned instance's methods that require macOS functionality will return errors.
|
||||
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
||||
return &KeychainUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
|
||||
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
||||
func getLongTermPrivateKey(_ afero.Fs, _ VaultInterface) (*memguard.LockedBuffer, error) {
|
||||
func getLongTermPrivateKey(
|
||||
_ afero.Fs, _ VaultInterface,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
return nil, errKeychainNotSupported
|
||||
}
|
||||
|
||||
@@ -13,29 +13,134 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// This test uses real filesystem
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Running in CI environment with real filesystem")
|
||||
}
|
||||
// testMnemonic is the standard BIP39 test vector mnemonic.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
// Create a temporary directory for our tests
|
||||
tempDir, err := os.MkdirTemp("", "secret-passphrase-test-")
|
||||
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
||||
func writeTestPublicKey(
|
||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
|
||||
err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, pubKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||
}
|
||||
|
||||
// Test data
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
testPassphrase := "test-passphrase-123"
|
||||
if !exists {
|
||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// Create the directory structure
|
||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
||||
// writeTestPrivateKey encrypts the private key with the passphrase,
|
||||
// writes it, and verifies it exists.
|
||||
func writeTestPrivateKey(
|
||||
t *testing.T,
|
||||
fs afero.Fs,
|
||||
unlockerDir string,
|
||||
agePrivateKey string,
|
||||
testPassphrase string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(
|
||||
privKeyBuffer, passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
|
||||
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, privKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if private key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// writeTestLongTermKey encrypts the derived long-term key to the
|
||||
// unlocker's recipient, writes it, and verifies it exists.
|
||||
func writeTestLongTermKey(
|
||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
// Derive a long-term identity from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to the unlocker's recipient
|
||||
recipient, err := age.ParseX25519Recipient(agePublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse recipient: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
|
||||
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, ltPrivKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if long-term key exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestPassphraseUnlocker creates a temp unlocker directory and a
|
||||
// passphrase unlocker with a fresh age identity for testing.
|
||||
func newTestPassphraseUnlocker(
|
||||
t *testing.T, fs afero.Fs,
|
||||
) (*secret.PassphraseUnlocker, *age.X25519Identity, string) {
|
||||
t.Helper()
|
||||
|
||||
// Create the directory structure in a temp dir
|
||||
unlockerDir := filepath.Join(t.TempDir(), "unlocker")
|
||||
|
||||
err := os.MkdirAll(unlockerDir, secret.DirPerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||
}
|
||||
|
||||
@@ -54,86 +159,40 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate age identity: %v", err)
|
||||
}
|
||||
|
||||
return unlocker, ageIdentity, unlockerDir
|
||||
}
|
||||
|
||||
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
|
||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
// This test uses real filesystem
|
||||
if os.Getenv("CI") == "true" {
|
||||
t.Log("Running in CI environment with real filesystem")
|
||||
}
|
||||
|
||||
// Use the real filesystem
|
||||
fs := afero.NewOsFs()
|
||||
|
||||
// Test data
|
||||
testPassphrase := "test-passphrase-123"
|
||||
|
||||
unlocker, ageIdentity, unlockerDir := newTestPassphraseUnlocker(t, fs)
|
||||
agePrivateKey := ageIdentity.String()
|
||||
agePublicKey := ageIdentity.Recipient().String()
|
||||
|
||||
// Test writing public key
|
||||
t.Run("WritePublicKey", func(t *testing.T) {
|
||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(agePublicKey), secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write public key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, pubKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||
}
|
||||
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
|
||||
})
|
||||
|
||||
// Test encrypting private key with passphrase
|
||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
||||
defer privKeyBuffer.Destroy()
|
||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphraseBuffer.Destroy()
|
||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphraseBuffer)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt private key: %v", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, privKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if private key exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Encrypted private key file should exist at %s", privKeyPath)
|
||||
}
|
||||
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
|
||||
})
|
||||
|
||||
// Test writing long-term key
|
||||
t.Run("WriteLongTermKey", func(t *testing.T) {
|
||||
// Derive a long-term identity from the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to derive long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Encrypt long-term private key to the unlocker's recipient
|
||||
recipient, err := age.ParseX25519Recipient(agePublicKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse recipient: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, recipient)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt long-term private key: %v", err)
|
||||
}
|
||||
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
||||
t.Fatalf("Failed to write encrypted long-term private key: %v", err)
|
||||
}
|
||||
|
||||
// Verify the file exists
|
||||
exists, err := afero.Exists(fs, ltPrivKeyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if long-term key exists: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Errorf("Encrypted long-term key file should exist at %s", ltPrivKeyPath)
|
||||
}
|
||||
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||
})
|
||||
|
||||
// Set test environment variable (cleaned up automatically)
|
||||
@@ -148,18 +207,21 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
|
||||
// Verify the identity matches what we expect
|
||||
expectedPubKey := ageIdentity.Recipient().String()
|
||||
|
||||
actualPubKey := identity.Recipient().String()
|
||||
if actualPubKey != expectedPubKey {
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||
expectedPubKey, actualPubKey)
|
||||
}
|
||||
})
|
||||
|
||||
// Unset the environment variable to test interactive prompt
|
||||
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||
|
||||
// Test getting identity from prompt (this would require mocking the prompt)
|
||||
// For real integration tests, we'd need to provide a way to mock the passphrase input
|
||||
// Here we'll just verify the error is what we expect when no passphrase is available
|
||||
// Test getting identity from prompt (this would require mocking the
|
||||
// prompt). For real integration tests, we'd need a way to mock the
|
||||
// passphrase input. Here we just verify the error is what we expect
|
||||
// when no passphrase is available.
|
||||
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
||||
// This should fail since we're not in an interactive terminal
|
||||
_, err := unlocker.GetIdentity()
|
||||
@@ -180,6 +242,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
t.Errorf("Unlocker directory should not exist after removal")
|
||||
}
|
||||
|
||||
@@ -19,37 +19,15 @@ type PassphraseUnlocker struct {
|
||||
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||
}
|
||||
|
||||
// getPassphrase retrieves the passphrase from memory, environment, or user input
|
||||
// Returns a LockedBuffer for secure memory handling
|
||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||
// First check if we already have the passphrase
|
||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||
// Return a copy of the passphrase buffer
|
||||
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||
func NewPassphraseUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *PassphraseUnlocker {
|
||||
return &PassphraseUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
|
||||
Debug("No passphrase in memory, checking environment")
|
||||
// Check environment variable for passphrase
|
||||
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
||||
if passphraseStr != "" {
|
||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||
// Convert to secure buffer
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
Debug("No passphrase in environment, prompting user")
|
||||
// Prompt for passphrase
|
||||
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for passphrase-based unlockers
|
||||
@@ -71,7 +49,8 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase unlocker private key", "error", err, "path", unlockerPrivPath)
|
||||
Debug("Failed to read passphrase unlocker private key",
|
||||
"error", err, "path", unlockerPrivPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
|
||||
}
|
||||
@@ -86,7 +65,8 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
// Decrypt the unlocker private key with passphrase
|
||||
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
|
||||
Debug("Failed to decrypt unlocker private key",
|
||||
"error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||
}
|
||||
@@ -135,7 +115,7 @@ func (p *PassphraseUnlocker) GetID() string {
|
||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||
createdAt := p.Metadata.CreatedAt
|
||||
|
||||
return fmt.Sprintf("%s-passphrase", createdAt.Format("2006-01-02.15.04"))
|
||||
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||
@@ -147,20 +127,45 @@ func (p *PassphraseUnlocker) Remove() error {
|
||||
|
||||
// For passphrase unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
err := p.fs.RemoveAll(p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
|
||||
return &PassphraseUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
// getPassphrase retrieves the passphrase from memory, environment, or
|
||||
// user input. Returns a LockedBuffer for secure memory handling
|
||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||
// First check if we already have the passphrase
|
||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||
// Return a copy of the passphrase buffer
|
||||
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
||||
}
|
||||
|
||||
Debug("No passphrase in memory, checking environment")
|
||||
// Check environment variable for passphrase
|
||||
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
||||
if passphraseStr != "" {
|
||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
||||
// Convert to secure buffer
|
||||
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
Debug("No passphrase in environment, prompting user")
|
||||
// Prompt for passphrase
|
||||
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||
if err != nil {
|
||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||
}
|
||||
|
||||
return secureBuffer, nil
|
||||
}
|
||||
|
||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||
|
||||
+130
-54
@@ -1,7 +1,9 @@
|
||||
package secret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -16,17 +18,28 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
||||
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
||||
errNoGPGFingerprint = errors.New("could not find fingerprint for GPG key")
|
||||
errNilDataBuffer = errors.New("data buffer is nil")
|
||||
)
|
||||
|
||||
// Variables to allow overriding in tests
|
||||
var (
|
||||
// GPGEncryptFunc is the function used for GPG encryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGEncryptFunc func(data *memguard.LockedBuffer, keyID string) ([]byte, error) = gpgEncryptDefault
|
||||
GPGEncryptFunc func(
|
||||
data *memguard.LockedBuffer, keyID string,
|
||||
) ([]byte, error) = gpgEncryptDefault
|
||||
|
||||
// GPGDecryptFunc is the function used for GPG decryption
|
||||
// Can be overridden in tests to provide a non-interactive implementation
|
||||
//nolint:gochecknoglobals // Required for test mocking
|
||||
GPGDecryptFunc func(encryptedData []byte) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||
GPGDecryptFunc func(
|
||||
encryptedData []byte,
|
||||
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
||||
|
||||
// gpgKeyIDRegex validates GPG key IDs
|
||||
// Allows either:
|
||||
@@ -45,6 +58,7 @@ var (
|
||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||
type PGPUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
// GPG key ID used for encryption
|
||||
GPGKeyID string `json:"gpgKeyId"`
|
||||
}
|
||||
@@ -56,6 +70,17 @@ type PGPUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewPGPUnlocker creates a new PGPUnlocker instance
|
||||
func NewPGPUnlocker(
|
||||
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||
) *PGPUnlocker {
|
||||
return &PGPUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
||||
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
DebugWith("Getting PGP unlocker identity",
|
||||
@@ -69,7 +94,8 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read PGP-encrypted age private key", "error", err, "path", agePrivKeyPath)
|
||||
Debug("Failed to read PGP-encrypted age private key",
|
||||
"error", err, "path", agePrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||
}
|
||||
@@ -81,9 +107,11 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 2: Decrypt the age private key using GPG
|
||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||
|
||||
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt age private key with GPG", "error", err, "unlocker_id", p.GetID())
|
||||
Debug("Failed to decrypt age private key with GPG",
|
||||
"error", err, "unlocker_id", p.GetID())
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
||||
}
|
||||
@@ -96,6 +124,7 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// Step 3: Parse the decrypted age private key
|
||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||
|
||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
||||
@@ -136,47 +165,43 @@ func (p *PGPUnlocker) GetID() string {
|
||||
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("pgp-%s", gpgKeyID)
|
||||
return "pgp-" + gpgKeyID
|
||||
}
|
||||
|
||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||
func (p *PGPUnlocker) Remove() error {
|
||||
// For PGP unlockers, we just need to remove the directory
|
||||
// No external resources (like keychain items) to clean up
|
||||
if err := p.fs.RemoveAll(p.Directory); err != nil {
|
||||
err := p.fs.RemoveAll(p.Directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPGPUnlocker creates a new PGPUnlocker instance
|
||||
func NewPGPUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PGPUnlocker {
|
||||
return &PGPUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetGPGKeyID returns the GPG key ID from metadata
|
||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||
// Load the metadata
|
||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
||||
|
||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
var pgpMetadata PGPUnlockerMetadata
|
||||
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
|
||||
|
||||
err = json.Unmarshal(metadataData, &pgpMetadata)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||
}
|
||||
|
||||
return pgpMetadata.GPGKeyID, nil
|
||||
}
|
||||
|
||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
|
||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||
// based on hostname and date
|
||||
func generatePGPUnlockerName() (string, error) {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
@@ -189,34 +214,55 @@ func generatePGPUnlockerName() (string, error) {
|
||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||
}
|
||||
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
|
||||
// preparePGPUnlockerDir checks GPG availability and creates the
|
||||
// unlocker directory in the current vault, returning the vault and the
|
||||
// directory path.
|
||||
//
|
||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
||||
func preparePGPUnlockerDir(
|
||||
fs afero.Fs, stateDir string,
|
||||
) (VaultInterface, string, error) {
|
||||
// Check if GPG is available
|
||||
if err := checkGPGAvailable(); err != nil {
|
||||
return nil, err
|
||||
err := checkGPGAvailable()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
// Get current vault
|
||||
vault, err := GetCurrentVault(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current vault: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||
}
|
||||
|
||||
// Generate the unlocker name based on hostname and date
|
||||
unlockerName, err := generatePGPUnlockerName()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||
}
|
||||
|
||||
// Create unlocker directory using the generated name
|
||||
vaultDir, err := vault.GetDirectory()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
|
||||
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||
|
||||
err = fs.MkdirAll(unlockerDir, DirPerms)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||
}
|
||||
|
||||
return vault, unlockerDir, nil
|
||||
}
|
||||
|
||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
||||
func CreatePGPUnlocker(
|
||||
fs afero.Fs, stateDir string, gpgKeyID string,
|
||||
) (*PGPUnlocker, error) {
|
||||
vault, unlockerDir, err := preparePGPUnlockerDir(fs, stateDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Step 1: Generate a new age keypair for the PGP unlocker
|
||||
@@ -228,7 +274,9 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
// Step 2: Store age recipient as plaintext
|
||||
ageRecipient := ageIdentity.Recipient().String()
|
||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
|
||||
|
||||
err = afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
||||
}
|
||||
|
||||
@@ -240,14 +288,18 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
defer ltPrivKeyData.Destroy()
|
||||
|
||||
// Step 7: Encrypt long-term private key to the new age unlocker
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
||||
ltPrivKeyData, ageIdentity.Recipient())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||
}
|
||||
|
||||
// Write encrypted long-term private key
|
||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
|
||||
|
||||
err = afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
@@ -262,17 +314,35 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
}
|
||||
|
||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
|
||||
|
||||
err = afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 9: Resolve the GPG key ID to its full fingerprint
|
||||
// Steps 9-10: Resolve the fingerprint and write enhanced metadata
|
||||
pgpMetadata, err := writePGPUnlockerMetadata(fs, unlockerDir, gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PGPUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: pgpMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// writePGPUnlockerMetadata resolves the GPG key fingerprint and writes
|
||||
// the unlocker metadata file, returning the metadata written.
|
||||
func writePGPUnlockerMetadata(
|
||||
fs afero.Fs, unlockerDir string, gpgKeyID string,
|
||||
) (*PGPUnlockerMetadata, error) {
|
||||
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Step 10: Create and write enhanced metadata with full fingerprint
|
||||
pgpMetadata := PGPUnlockerMetadata{
|
||||
UnlockerMetadata: UnlockerMetadata{
|
||||
Type: "pgp",
|
||||
@@ -287,27 +357,24 @@ func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnloc
|
||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
if err := afero.WriteFile(fs,
|
||||
err = afero.WriteFile(fs,
|
||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
||||
metadataBytes, FilePerms); err != nil {
|
||||
metadataBytes, FilePerms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||
}
|
||||
|
||||
return &PGPUnlocker{
|
||||
Directory: unlockerDir,
|
||||
Metadata: pgpMetadata.UnlockerMetadata,
|
||||
fs: fs,
|
||||
}, nil
|
||||
return &pgpMetadata, nil
|
||||
}
|
||||
|
||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||
func validateGPGKeyID(keyID string) error {
|
||||
if keyID == "" {
|
||||
return fmt.Errorf("GPG key ID cannot be empty")
|
||||
return errGPGKeyIDEmpty
|
||||
}
|
||||
|
||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
|
||||
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -315,22 +382,24 @@ func validateGPGKeyID(keyID string) error {
|
||||
|
||||
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
err := validateGPGKeyID(keyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
// Use GPG to get the full fingerprint for the key
|
||||
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
||||
context.Background(),
|
||||
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||
)
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||
}
|
||||
|
||||
// Parse the output to extract the fingerprint
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
if strings.HasPrefix(line, "fpr:") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[9] != "" {
|
||||
@@ -339,14 +408,18 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("could not find fingerprint for GPG key: %s", keyID)
|
||||
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
|
||||
}
|
||||
|
||||
// checkGPGAvailable verifies that GPG is available
|
||||
func checkGPGAvailable() error {
|
||||
cmd := exec.Command("gpg", "--version")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("GPG not available: %w (make sure 'gpg' command is installed and in PATH)", err)
|
||||
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
|
||||
err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -355,13 +428,16 @@ func checkGPGAvailable() error {
|
||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("data buffer is nil")
|
||||
return nil, errNilDataBuffer
|
||||
}
|
||||
if err := validateGPGKeyID(keyID); err != nil {
|
||||
|
||||
err := validateGPGKeyID(keyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command( // #nosec G204 -- keyID validated
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
||||
context.Background(),
|
||||
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
||||
)
|
||||
cmd.Stdin = strings.NewReader(data.String())
|
||||
@@ -376,7 +452,7 @@ func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error
|
||||
|
||||
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||
cmd := exec.Command("gpg", "--quiet", "--decrypt")
|
||||
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
|
||||
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||
|
||||
output, err := cmd.Output()
|
||||
|
||||
+162
-101
@@ -2,6 +2,7 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -15,6 +16,18 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var (
|
||||
// errSecretNotFound carries only the message tail; callers compose
|
||||
// "secret <name> not found" around it so the emitted text is
|
||||
// unchanged.
|
||||
errSecretNotFound = errors.New("not found")
|
||||
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
|
||||
errGetEncryptedDataDeprecated = errors.New(
|
||||
"GetEncryptedData is deprecated - use version-specific methods")
|
||||
errGetCurrentVaultNotRegistered = errors.New(
|
||||
"GetCurrentVault function not registered")
|
||||
)
|
||||
|
||||
// VaultInterface defines the interface that vault implementations must satisfy
|
||||
type VaultInterface interface {
|
||||
GetDirectory() (string, error)
|
||||
@@ -22,7 +35,8 @@ type VaultInterface interface {
|
||||
GetName() string
|
||||
GetFilesystem() afero.Fs
|
||||
GetCurrentUnlocker() (Unlocker, error)
|
||||
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
CreatePassphraseUnlocker(
|
||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||
}
|
||||
|
||||
// Secret represents a secret in a vault
|
||||
@@ -62,7 +76,8 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
}
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the current version's value using the provided unlocker
|
||||
// GetValue retrieves and decrypts the current version's value using the
|
||||
// provided unlocker
|
||||
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
DebugWith("Getting secret value",
|
||||
slog.String("secret_name", s.Name),
|
||||
@@ -72,14 +87,17 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
// Check if secret exists
|
||||
exists, err := s.Exists()
|
||||
if err != nil {
|
||||
Debug("Failed to check if secret exists during GetValue", "error", err, "secret_name", s.Name)
|
||||
Debug("Failed to check if secret exists during GetValue",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
Debug("Secret not found during GetValue", "secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||
|
||||
return nil, fmt.Errorf("secret %s not found", s.Name)
|
||||
if !exists {
|
||||
Debug("Secret not found during GetValue",
|
||||
"secret_name", s.Name, "vault_name", s.vault.GetName())
|
||||
|
||||
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
|
||||
}
|
||||
|
||||
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
||||
@@ -95,52 +113,9 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
// Create version object
|
||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
||||
|
||||
// Check if we have SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
|
||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||
Debug("Using mnemonic from environment for direct long-term key derivation", "secret_name", s.Name)
|
||||
|
||||
// Get vault directory to read metadata
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get the correct derivation index
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Using vault derivation index from metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Use mnemonic with the vault's derivation index from metadata
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from mnemonic for secret", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
return s.getValueViaMnemonic(version, envMnemonic)
|
||||
}
|
||||
|
||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
||||
@@ -149,51 +124,12 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
if unlocker == nil {
|
||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("unlocker required to decrypt secret")
|
||||
return nil, errUnlockerRequired
|
||||
}
|
||||
|
||||
DebugWith("Getting vault's long-term key using unlocker",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
|
||||
if err != nil {
|
||||
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key from the unlocker directory
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key", "error", err, "path", encryptedLtPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the encrypted long-term private key using the unlocker
|
||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
// Parse the long-term private key
|
||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term private key", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
DebugWith("Successfully obtained vault's long-term key",
|
||||
@@ -207,7 +143,8 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
s.Metadata = Metadata{
|
||||
@@ -227,9 +164,10 @@ func (s *Secret) GetMetadata() Metadata {
|
||||
|
||||
// GetEncryptedData is deprecated - data is now stored in versions
|
||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
Debug("GetEncryptedData called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("GetEncryptedData is deprecated - use version-specific methods")
|
||||
return nil, errGetEncryptedDataDeprecated
|
||||
}
|
||||
|
||||
// Exists checks if the secret exists on disk
|
||||
@@ -242,7 +180,8 @@ func (s *Secret) Exists() (bool, error) {
|
||||
// Check if the secret directory exists and has a current symlink
|
||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||
if err != nil {
|
||||
Debug("Failed to check secret directory existence", "error", err, "secret_dir", s.Directory)
|
||||
Debug("Failed to check secret directory existence",
|
||||
"error", err, "secret_dir", s.Directory)
|
||||
|
||||
return false, err
|
||||
}
|
||||
@@ -269,14 +208,134 @@ func (s *Secret) Exists() (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// getValueViaMnemonic derives the vault's long-term key from the
|
||||
// mnemonic in the environment and decrypts the version value with it.
|
||||
func (s *Secret) getValueViaMnemonic(
|
||||
version *Version, envMnemonic string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
Debug("Using mnemonic from environment for direct long-term key derivation",
|
||||
"secret_name", s.Name)
|
||||
|
||||
// Get vault directory to read metadata
|
||||
vaultDir, err := s.vault.GetDirectory()
|
||||
if err != nil {
|
||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||
}
|
||||
|
||||
// Load vault metadata to get the correct derivation index
|
||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||
|
||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||
}
|
||||
|
||||
var metadata VaultMetadata
|
||||
|
||||
err = json.Unmarshal(metadataBytes, &metadata)
|
||||
if err != nil {
|
||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||
}
|
||||
|
||||
DebugWith("Using vault derivation index from metadata",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("vault_name", s.vault.GetName()),
|
||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||
)
|
||||
|
||||
// Use mnemonic with the vault's derivation index from metadata
|
||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||
if err != nil {
|
||||
Debug("Failed to derive long-term key from mnemonic for secret",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to derive long-term key from mnemonic: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
||||
|
||||
// Use the long-term key to decrypt the version
|
||||
return version.GetValue(ltIdentity)
|
||||
}
|
||||
|
||||
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
|
||||
// the vault's long-term private key.
|
||||
func (s *Secret) getLongTermIdentityFromUnlocker(
|
||||
unlocker Unlocker,
|
||||
) (*age.X25519Identity, error) {
|
||||
DebugWith("Getting vault's long-term key using unlocker",
|
||||
slog.String("secret_name", s.Name),
|
||||
slog.String("unlocker_type", unlocker.GetType()),
|
||||
slog.String("unlocker_id", unlocker.GetID()),
|
||||
)
|
||||
|
||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
||||
unlockIdentity, err := unlocker.GetIdentity()
|
||||
if err != nil {
|
||||
Debug("Failed to get unlocker identity",
|
||||
"error", err, "secret_name", s.Name,
|
||||
"unlocker_type", unlocker.GetType())
|
||||
|
||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
||||
}
|
||||
|
||||
// Read the encrypted long-term private key from the unlocker directory
|
||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
||||
|
||||
encryptedLtPrivKey, err := afero.ReadFile(
|
||||
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted long-term private key",
|
||||
"error", err, "path", encryptedLtPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read encrypted long-term private key: %w", err)
|
||||
}
|
||||
|
||||
// Decrypt the encrypted long-term private key using the unlocker
|
||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
||||
|
||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt long-term private key",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||
}
|
||||
defer ltPrivKeyBuffer.Destroy()
|
||||
|
||||
// Parse the long-term private key
|
||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
||||
|
||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term private key",
|
||||
"error", err, "secret_name", s.Name)
|
||||
|
||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
||||
}
|
||||
|
||||
return ltIdentity, nil
|
||||
}
|
||||
|
||||
// GetCurrentVault gets the current vault from the file system
|
||||
// This function is a wrapper around the actual implementation in the vault package
|
||||
// and exists to break the import cycle.
|
||||
//
|
||||
//nolint:ireturn // must return the interface to break the import cycle
|
||||
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||
// This is a forward declaration. The actual implementation is provided
|
||||
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
||||
if getCurrentVaultFunc == nil {
|
||||
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
||||
return nil, errGetCurrentVaultNotRegistered
|
||||
}
|
||||
|
||||
return getCurrentVaultFunc(fs, stateDir)
|
||||
@@ -288,8 +347,10 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||
//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
|
||||
// of GetCurrentVault to break the import cycle
|
||||
func RegisterGetCurrentVaultFunc(fn func(fs afero.Fs, stateDir string) (VaultInterface, error)) {
|
||||
// RegisterGetCurrentVaultFunc allows the vault package to register its
|
||||
// implementation of GetCurrentVault to break the import cycle
|
||||
func RegisterGetCurrentVaultFunc(
|
||||
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
|
||||
) {
|
||||
getCurrentVaultFunc = fn
|
||||
}
|
||||
|
||||
+135
-73
@@ -1,7 +1,8 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -14,6 +15,17 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
||||
//
|
||||
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||
const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
|
||||
"abandon abandon abandon abandon abandon about"
|
||||
|
||||
var (
|
||||
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
|
||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
||||
)
|
||||
|
||||
// MockVault is a test implementation of the VaultInterface
|
||||
type MockVault struct {
|
||||
name string
|
||||
@@ -30,14 +42,18 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
// Create secret directory with proper storage name conversion
|
||||
storageName := strings.ReplaceAll(name, "/", "%")
|
||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
||||
if err := m.fs.MkdirAll(secretDir, 0o700); err != nil {
|
||||
|
||||
err := m.fs.MkdirAll(secretDir, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create version directory with proper path
|
||||
versionName := "20240101.001" // Use a fixed version name for testing
|
||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
||||
if err := m.fs.MkdirAll(versionDir, 0o700); err != nil {
|
||||
|
||||
err = m.fs.MkdirAll(versionDir, 0o700)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -47,7 +63,7 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
// Derive long-term key using the vault's derivation index
|
||||
mnemonic := os.Getenv(EnvMnemonic)
|
||||
if mnemonic == "" {
|
||||
return fmt.Errorf("SB_SECRET_MNEMONIC not set")
|
||||
return errMnemonicNotSet
|
||||
}
|
||||
|
||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
||||
@@ -56,13 +72,54 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
}
|
||||
|
||||
// Write long-term public key if it doesn't exist
|
||||
if _, err := m.fs.Stat(ltPubKeyPath); os.IsNotExist(err) {
|
||||
_, err = m.fs.Stat(ltPubKeyPath)
|
||||
if os.IsNotExist(err) {
|
||||
pubKey := ltIdentity.Recipient().String()
|
||||
if err := afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600); err != nil {
|
||||
|
||||
err = afero.WriteFile(m.fs, ltPubKeyPath, []byte(pubKey), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = m.writeVersionFiles(versionDir, value, ltIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create current file pointing to the version (just the version name)
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
|
||||
return afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600)
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*PassphraseUnlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
// writeVersionFiles generates a version keypair and writes the version
|
||||
// key and value files for the mock vault.
|
||||
func (m *MockVault) writeVersionFiles(
|
||||
versionDir string,
|
||||
value *memguard.LockedBuffer,
|
||||
ltIdentity *age.X25519Identity,
|
||||
) error {
|
||||
// Generate version-specific keypair
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
@@ -71,7 +128,10 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
|
||||
// Write version public key
|
||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
||||
if err := afero.WriteFile(m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600); err != nil {
|
||||
|
||||
err = afero.WriteFile(
|
||||
m.fs, pubKeyPath, []byte(versionIdentity.Recipient().String()), 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -83,60 +143,32 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
||||
|
||||
// Write encrypted value
|
||||
valuePath := filepath.Join(versionDir, "value.age")
|
||||
if err := afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600); err != nil {
|
||||
|
||||
err = afero.WriteFile(m.fs, valuePath, encryptedValue, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Encrypt version private key to long-term public key
|
||||
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||
|
||||
encryptedPrivKey, err := EncryptToRecipient(
|
||||
versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write encrypted version private key
|
||||
privKeyPath := filepath.Join(versionDir, "priv.age")
|
||||
if err := afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create current file pointing to the version (just the version name)
|
||||
currentLink := filepath.Join(secretDir, "current")
|
||||
if err := afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
|
||||
}
|
||||
|
||||
func (m *MockVault) GetName() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
func (m *MockVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set test mnemonic for direct encryption/decryption
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(EnvMnemonic, testMnemonic)
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
||||
// setupMockVaultDirs creates the vault directory structure, long-term
|
||||
// public key, and current vault pointer for tests.
|
||||
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
||||
t.Helper()
|
||||
|
||||
// Create vault directory structure
|
||||
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
||||
@@ -145,13 +177,14 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
}
|
||||
|
||||
// Generate a long-term keypair for the vault using the test mnemonic
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonicValue, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate long-term identity: %v", err)
|
||||
}
|
||||
|
||||
// Write long-term public key
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
|
||||
err = afero.WriteFile(
|
||||
fs,
|
||||
ltPubKeyPath,
|
||||
@@ -164,10 +197,56 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
|
||||
// Set current vault
|
||||
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
||||
|
||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to set current vault: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// verifySecretFiles checks that AddSecret created the expected version
|
||||
// files for the secret.
|
||||
func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
|
||||
t.Helper()
|
||||
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
// Check current file exists and points at a version
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
|
||||
valueExists, err := afero.Exists(fs, filepath.Join(versionDir, "value.age"))
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:paralleltest // uses t.Setenv (process-global environment)
|
||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
// Create an in-memory filesystem for testing
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
// Set test mnemonic for direct encryption/decryption
|
||||
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||
|
||||
// Set up a test vault structure
|
||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
|
||||
|
||||
setupMockVaultDirs(t, fs, baseDir, vaultDir)
|
||||
|
||||
// Create vault instance using the mock vault
|
||||
vault := &MockVault{
|
||||
@@ -193,30 +272,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify that all expected files were created
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
||||
|
||||
// Check versions directory exists
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
versionsDirExists, err := afero.DirExists(fs, versionsDir)
|
||||
if err != nil || !versionsDirExists {
|
||||
t.Fatalf("versions directory was not created")
|
||||
}
|
||||
|
||||
// Check current symlink exists
|
||||
currentVersion, err := GetCurrentVersion(fs, secretDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get current version: %v", err)
|
||||
}
|
||||
|
||||
// Check value.age exists in the version directory
|
||||
versionDir := filepath.Join(versionsDir, currentVersion)
|
||||
valueExists, err := afero.Exists(
|
||||
fs,
|
||||
filepath.Join(versionDir, "value.age"),
|
||||
)
|
||||
if err != nil || !valueExists {
|
||||
t.Fatalf("value.age file was not created in version directory")
|
||||
}
|
||||
verifySecretFiles(t, fs, vaultDir, secretName)
|
||||
|
||||
t.Logf("All expected files created successfully with versioning")
|
||||
})
|
||||
@@ -245,9 +301,11 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Error checking if secret exists: %v", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
t.Fatalf("Secret should exist but Exists() returned false")
|
||||
}
|
||||
|
||||
t.Logf("Secret.Exists() works correctly")
|
||||
})
|
||||
}
|
||||
@@ -274,6 +332,8 @@ func isValidSecretName(name string) bool {
|
||||
}
|
||||
|
||||
func TestSecretNameValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
valid bool
|
||||
@@ -293,6 +353,8 @@ func TestSecretNameValidation(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
result := isValidSecretName(test.name)
|
||||
if result != test.valid {
|
||||
t.Errorf(
|
||||
@@ -311,13 +373,13 @@ func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
||||
// instead of the vault's actual derivation index when using environment mnemonic
|
||||
|
||||
// Set up test mnemonic
|
||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
||||
t.Setenv(EnvMnemonic, testMnemonic)
|
||||
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||
|
||||
// Create temporary directory for vaults
|
||||
fs := afero.NewOsFs()
|
||||
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() {
|
||||
_ = fs.RemoveAll(tempDir)
|
||||
}()
|
||||
|
||||
@@ -1,22 +1,25 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
package secret
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"errors"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/spf13/afero"
|
||||
)
|
||||
|
||||
var errSENotSupported = fmt.Errorf(
|
||||
// seUnlockerType is the type string for Secure Enclave unlockers.
|
||||
const seUnlockerType = "secure-enclave"
|
||||
|
||||
var errSENotSupported = errors.New(
|
||||
"secure enclave unlockers are only supported on macOS",
|
||||
)
|
||||
|
||||
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
||||
type SecureEnclaveUnlockerMetadata struct {
|
||||
UnlockerMetadata
|
||||
|
||||
SEKeyLabel string `json:"seKeyLabel"`
|
||||
SEKeyHash string `json:"seKeyHash"`
|
||||
}
|
||||
@@ -28,6 +31,21 @@ type SecureEnclaveUnlocker struct {
|
||||
fs afero.Fs
|
||||
}
|
||||
|
||||
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on
|
||||
// non-Darwin platforms. The returned instance's methods that require
|
||||
// macOS functionality will return errors.
|
||||
func NewSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
directory string,
|
||||
metadata UnlockerMetadata,
|
||||
) *SecureEnclaveUnlocker {
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// GetIdentity returns an error on non-Darwin platforms.
|
||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
return nil, errSENotSupported
|
||||
@@ -35,7 +53,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
|
||||
// GetType returns the unlocker type.
|
||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||
return "secure-enclave"
|
||||
return seUnlockerType
|
||||
}
|
||||
|
||||
// GetMetadata returns the unlocker metadata.
|
||||
@@ -50,10 +68,7 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
||||
|
||||
// GetID returns the unlocker ID.
|
||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||
return fmt.Sprintf(
|
||||
"%s-secure-enclave",
|
||||
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
|
||||
)
|
||||
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
|
||||
}
|
||||
|
||||
// Remove returns an error on non-Darwin platforms.
|
||||
@@ -61,20 +76,6 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
||||
return errSENotSupported
|
||||
}
|
||||
|
||||
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms.
|
||||
// The returned instance's methods that require macOS functionality will return errors.
|
||||
func NewSecureEnclaveUnlocker(
|
||||
fs afero.Fs,
|
||||
directory string,
|
||||
metadata UnlockerMetadata,
|
||||
) *SecureEnclaveUnlocker {
|
||||
return &SecureEnclaveUnlocker{
|
||||
Directory: directory,
|
||||
Metadata: metadata,
|
||||
fs: fs,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
|
||||
func CreateSecureEnclaveUnlocker(
|
||||
_ afero.Fs,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//go:build !darwin
|
||||
// +build !darwin
|
||||
|
||||
//nolint:testpackage // white-box test asserting unexported sentinel errors
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -13,19 +13,21 @@ import (
|
||||
)
|
||||
|
||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
dir := "/tmp/test-se-unlocker"
|
||||
metadata := UnlockerMetadata{
|
||||
Type: "secure-enclave",
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||
Flags: []string{"secure-enclave", "macos"},
|
||||
Flags: []string{seUnlockerType, "macos"},
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||
|
||||
// Test GetType returns correct type
|
||||
assert.Equal(t, "secure-enclave", unlocker.GetType())
|
||||
assert.Equal(t, seUnlockerType, unlocker.GetType())
|
||||
|
||||
// Test GetMetadata returns the metadata we passed in
|
||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||
@@ -39,9 +41,11 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: "secure-enclave",
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
@@ -49,37 +53,43 @@ func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||
|
||||
identity, err := unlocker.GetIdentity()
|
||||
assert.Nil(t, identity)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSENotSupported)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: "secure-enclave",
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||
|
||||
err := unlocker.Remove()
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSENotSupported)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
||||
assert.Nil(t, unlocker)
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, errSENotSupported)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errSENotSupported)
|
||||
}
|
||||
|
||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
metadata := UnlockerMetadata{
|
||||
Type: "secure-enclave",
|
||||
Type: seUnlockerType,
|
||||
CreatedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package secret
|
||||
|
||||
import (
|
||||
@@ -5,148 +6,60 @@ import (
|
||||
)
|
||||
|
||||
func TestValidateGPGKeyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
keyID string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{"valid email address", "test@example.com", false},
|
||||
{"valid email with dots and hyphens", "test.user-name@example-domain.co.uk", false},
|
||||
{"valid email with plus", "test+tag@example.com", false},
|
||||
{"valid short key ID (8 hex chars)", "ABCDEF12", false},
|
||||
{"valid long key ID (16 hex chars)", "ABCDEF1234567890", false},
|
||||
{
|
||||
name: "valid email address",
|
||||
keyID: "test@example.com",
|
||||
wantErr: false,
|
||||
"valid fingerprint (40 hex chars)",
|
||||
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
|
||||
},
|
||||
{
|
||||
name: "valid email with dots and hyphens",
|
||||
keyID: "test.user-name@example-domain.co.uk",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid email with plus",
|
||||
keyID: "test+tag@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid short key ID (8 hex chars)",
|
||||
keyID: "ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid long key ID (16 hex chars)",
|
||||
keyID: "ABCDEF1234567890",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid fingerprint (40 hex chars)",
|
||||
keyID: "ABCDEF1234567890ABCDEF1234567890ABCDEF12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid lowercase hex fingerprint",
|
||||
keyID: "abcdef1234567890abcdef1234567890abcdef12",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid mixed case hex",
|
||||
keyID: "AbCdEf1234567890",
|
||||
wantErr: false,
|
||||
"valid lowercase hex fingerprint",
|
||||
"abcdef1234567890abcdef1234567890abcdef12", false,
|
||||
},
|
||||
{"valid mixed case hex", "AbCdEf1234567890", false},
|
||||
|
||||
// Invalid cases
|
||||
{"empty key ID", "", true},
|
||||
{"key ID with spaces", "test user@example.com", true},
|
||||
{"key ID with semicolon (command injection)", "test@example.com; rm -rf /", true},
|
||||
{
|
||||
name: "empty key ID",
|
||||
keyID: "",
|
||||
wantErr: true,
|
||||
"key ID with pipe (command injection)",
|
||||
"test@example.com | cat /etc/passwd", true,
|
||||
},
|
||||
{"key ID with backticks (command injection)", "test@example.com`whoami`", true},
|
||||
{
|
||||
name: "key ID with spaces",
|
||||
keyID: "test user@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with semicolon (command injection)",
|
||||
keyID: "test@example.com; rm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with pipe (command injection)",
|
||||
keyID: "test@example.com | cat /etc/passwd",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backticks (command injection)",
|
||||
keyID: "test@example.com`whoami`",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with dollar sign (command injection)",
|
||||
keyID: "test@example.com$(whoami)",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with quotes",
|
||||
keyID: "test\"@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with single quotes",
|
||||
keyID: "test'@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with backslash",
|
||||
keyID: "test\\@example.com",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with newline",
|
||||
keyID: "test@example.com\nrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with carriage return",
|
||||
keyID: "test@example.com\rrm -rf /",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (7 chars)",
|
||||
keyID: "ABCDEF1",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with invalid length (9 chars)",
|
||||
keyID: "ABCDEF123",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "hex with non-hex characters",
|
||||
keyID: "ABCDEFGH",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "mixed format (email with hex)",
|
||||
keyID: "test@ABCDEF12",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with ampersand",
|
||||
keyID: "test@example.com & echo test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with redirect",
|
||||
keyID: "test@example.com > /tmp/test",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "key ID with null byte",
|
||||
keyID: "test@example.com\x00",
|
||||
wantErr: true,
|
||||
"key ID with dollar sign (command injection)",
|
||||
"test@example.com$(whoami)", true,
|
||||
},
|
||||
{"key ID with quotes", "test\"@example.com", true},
|
||||
{"key ID with single quotes", "test'@example.com", true},
|
||||
{"key ID with backslash", "test\\@example.com", true},
|
||||
{"key ID with newline", "test@example.com\nrm -rf /", true},
|
||||
{"key ID with carriage return", "test@example.com\rrm -rf /", true},
|
||||
{"hex with invalid length (7 chars)", "ABCDEF1", true},
|
||||
{"hex with invalid length (9 chars)", "ABCDEF123", true},
|
||||
{"hex with non-hex characters", "ABCDEFGH", true},
|
||||
{"mixed format (email with hex)", "test@ABCDEF12", true},
|
||||
{"key ID with ampersand", "test@example.com & echo test", true},
|
||||
{"key ID with redirect", "test@example.com > /tmp/test", true},
|
||||
{"key ID with null byte", "test@example.com\x00", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := validateGPGKeyID(tt.keyID)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
|
||||
+200
-107
@@ -2,6 +2,7 @@ package secret
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
@@ -20,12 +21,17 @@ const (
|
||||
maxVersionsPerDay = 999
|
||||
)
|
||||
|
||||
var (
|
||||
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
||||
errNilValueBuffer = errors.New("value buffer is nil")
|
||||
)
|
||||
|
||||
// VersionMetadata contains information about a secret version
|
||||
type VersionMetadata struct {
|
||||
ID string `json:"id"` // ULID
|
||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // When this version expires (nil = current)
|
||||
NotAfter *time.Time `json:"notAfter,omitempty"` // Expiry (nil = current)
|
||||
}
|
||||
|
||||
// Version represents a version of a secret
|
||||
@@ -75,7 +81,8 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Ensure versions directory exists
|
||||
if err := fs.MkdirAll(versionsDir, DirPerms); err != nil {
|
||||
err := fs.MkdirAll(versionsDir, DirPerms)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create versions directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -101,8 +108,11 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
}
|
||||
|
||||
var serial int
|
||||
if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
|
||||
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
|
||||
|
||||
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
|
||||
if err != nil {
|
||||
Warn("Skipping malformed version directory name",
|
||||
"name", entry.Name(), "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
@@ -115,7 +125,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
// Generate new version name
|
||||
newSerial := maxSerial + 1
|
||||
if newSerial > maxVersionsPerDay {
|
||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
||||
return "", errMaxVersionsPerDay
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
||||
@@ -124,7 +134,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
||||
// Save saves the version metadata and value
|
||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("value buffer is nil")
|
||||
return errNilValueBuffer
|
||||
}
|
||||
|
||||
DebugWith("Saving secret version",
|
||||
@@ -136,14 +146,16 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
fs := sv.vault.GetFilesystem()
|
||||
|
||||
// Create version directory
|
||||
if err := fs.MkdirAll(sv.Directory, DirPerms); err != nil {
|
||||
err := fs.MkdirAll(sv.Directory, DirPerms)
|
||||
if err != nil {
|
||||
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||
|
||||
return fmt.Errorf("failed to create version directory: %w", err)
|
||||
}
|
||||
|
||||
// Step 1: Generate a new keypair for this version
|
||||
// Generate a new keypair for this version
|
||||
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||
|
||||
versionIdentity, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
||||
@@ -151,110 +163,33 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||
}
|
||||
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
// Store private key in memguard buffer immediately
|
||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
|
||||
[]byte(versionIdentity.String()))
|
||||
defer versionPrivateKeyBuffer.Destroy()
|
||||
|
||||
DebugWith("Generated version keypair",
|
||||
slog.String("version", sv.Version),
|
||||
slog.String("public_key", versionPublicKey),
|
||||
slog.String("public_key", versionIdentity.Recipient().String()),
|
||||
)
|
||||
|
||||
// Step 2: Store the version's public key
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
if err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms); err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: Encrypt the value to the version's public key
|
||||
Debug("Encrypting value to version's public key", "version", sv.Version)
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 4: Store the encrypted value
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
if err := afero.WriteFile(fs, valuePath, encryptedValue, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Get vault's long-term public key for encrypting the version's private key
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
err = sv.writeEncryptedMetadata(fs, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 6: Encrypt the version's private key to the long-term public key
|
||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Store the encrypted private key
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
if err := afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version private key", "error", err, "path", privKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
// Step 8: Encrypt and store metadata
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms); err != nil {
|
||||
Debug("Failed to write encrypted version metadata", "error", err, "path", metadataPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
Debug("Successfully saved secret version", "version", sv.Version, "secret_name", sv.SecretName)
|
||||
Debug("Successfully saved secret version",
|
||||
"version", sv.Version, "secret_name", sv.SecretName)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -270,9 +205,11 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||
Debug("Failed to read encrypted version private key",
|
||||
"error", err, "path", encryptedPrivKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
@@ -296,9 +233,11 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// Step 4: Read encrypted metadata
|
||||
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
|
||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version metadata", "error", err, "path", encryptedMetadataPath)
|
||||
Debug("Failed to read encrypted version metadata",
|
||||
"error", err, "path", encryptedMetadataPath)
|
||||
|
||||
return fmt.Errorf("failed to read encrypted version metadata: %w", err)
|
||||
}
|
||||
@@ -314,20 +253,25 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
||||
|
||||
// Step 6: Unmarshal metadata
|
||||
var metadata VersionMetadata
|
||||
if err := json.Unmarshal(metadataBuffer.Bytes(), &metadata); err != nil {
|
||||
|
||||
err = json.Unmarshal(metadataBuffer.Bytes(), &metadata)
|
||||
if err != nil {
|
||||
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
sv.Metadata = metadata
|
||||
|
||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue retrieves and decrypts the version value
|
||||
func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuffer, error) {
|
||||
func (sv *Version) GetValue(
|
||||
ltIdentity *age.X25519Identity,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
DebugWith("Getting version value",
|
||||
slog.String("secret_name", sv.SecretName),
|
||||
slog.String("version", sv.Version),
|
||||
@@ -345,16 +289,22 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
|
||||
// Step 1: Read encrypted version private key
|
||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
||||
|
||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version private key", "error", err, "path", encryptedPrivKeyPath)
|
||||
Debug("Failed to read encrypted version private key",
|
||||
"error", err, "path", encryptedPrivKeyPath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||
return nil, fmt.Errorf(
|
||||
"failed to read encrypted version private key: %w", err)
|
||||
}
|
||||
Debug("Successfully read encrypted version private key", "path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
||||
|
||||
Debug("Successfully read encrypted version private key",
|
||||
"path", encryptedPrivKeyPath, "size", len(encryptedPrivKey))
|
||||
|
||||
// Step 2: Decrypt version private key using long-term key
|
||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||
|
||||
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
||||
@@ -362,7 +312,9 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
|
||||
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||
}
|
||||
defer versionPrivKeyBuffer.Destroy()
|
||||
Debug("Successfully decrypted version private key", "version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||
|
||||
Debug("Successfully decrypted version private key",
|
||||
"version", sv.Version, "size", versionPrivKeyBuffer.Size())
|
||||
|
||||
// Step 3: Parse version private key
|
||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||
@@ -375,16 +327,21 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
|
||||
// Step 4: Read encrypted value
|
||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Reading encrypted value", "path", encryptedValuePath)
|
||||
|
||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||
if err != nil {
|
||||
Debug("Failed to read encrypted version value", "error", err, "path", encryptedValuePath)
|
||||
Debug("Failed to read encrypted version value",
|
||||
"error", err, "path", encryptedValuePath)
|
||||
|
||||
return nil, fmt.Errorf("failed to read encrypted version value: %w", err)
|
||||
}
|
||||
Debug("Successfully read encrypted value", "path", encryptedValuePath, "size", len(encryptedValue))
|
||||
|
||||
Debug("Successfully read encrypted value",
|
||||
"path", encryptedValuePath, "size", len(encryptedValue))
|
||||
|
||||
// Step 5: Decrypt value using version key
|
||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||
|
||||
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||
if err != nil {
|
||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
||||
@@ -400,6 +357,139 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
|
||||
return valueBuffer, nil
|
||||
}
|
||||
|
||||
// writePublicKeyAndValue stores the version's public key and the value
|
||||
// encrypted to it.
|
||||
func (sv *Version) writePublicKeyAndValue(
|
||||
fs afero.Fs,
|
||||
versionIdentity *age.X25519Identity,
|
||||
value *memguard.LockedBuffer,
|
||||
) error {
|
||||
versionPublicKey := versionIdentity.Recipient().String()
|
||||
pubKeyPath := filepath.Join(sv.Directory, "pub.age")
|
||||
Debug("Writing version public key", "path", pubKeyPath)
|
||||
|
||||
err := afero.WriteFile(fs, pubKeyPath, []byte(versionPublicKey), FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write version public key", "error", err, "path", pubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write version public key: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the value to the version's public key
|
||||
Debug("Encrypting value to version's public key", "version", sv.Version)
|
||||
|
||||
encryptedValue, err := EncryptToRecipient(value, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join(sv.Directory, "value.age")
|
||||
Debug("Writing encrypted version value", "path", valuePath)
|
||||
|
||||
err = afero.WriteFile(fs, valuePath, encryptedValue, FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version value", "error", err, "path", valuePath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version value: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEncryptedPrivateKey encrypts the version's private key to the
|
||||
// vault's long-term public key and stores it.
|
||||
func (sv *Version) writeEncryptedPrivateKey(
|
||||
fs afero.Fs,
|
||||
versionPrivateKeyBuffer *memguard.LockedBuffer,
|
||||
) error {
|
||||
vaultDir, _ := sv.vault.GetDirectory()
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
Debug("Reading long-term public key", "path", ltPubKeyPath)
|
||||
|
||||
ltPubKeyData, err := afero.ReadFile(fs, ltPubKeyPath)
|
||||
if err != nil {
|
||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Parsing long-term public key")
|
||||
|
||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
||||
if err != nil {
|
||||
Debug("Failed to parse long-term public key", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
||||
}
|
||||
|
||||
Debug("Encrypting version private key to long-term public key",
|
||||
"version", sv.Version)
|
||||
|
||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivateKeyBuffer, ltRecipient)
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version private key",
|
||||
"error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version private key: %w", err)
|
||||
}
|
||||
|
||||
privKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||
Debug("Writing encrypted version private key", "path", privKeyPath)
|
||||
|
||||
err = afero.WriteFile(fs, privKeyPath, encryptedPrivKey, FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version private key",
|
||||
"error", err, "path", privKeyPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version private key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeEncryptedMetadata encrypts the version metadata to the version's
|
||||
// public key and stores it.
|
||||
func (sv *Version) writeEncryptedMetadata(
|
||||
fs afero.Fs,
|
||||
versionIdentity *age.X25519Identity,
|
||||
) error {
|
||||
Debug("Encrypting version metadata", "version", sv.Version)
|
||||
|
||||
metadataBytes, err := json.MarshalIndent(sv.Metadata, "", " ")
|
||||
if err != nil {
|
||||
Debug("Failed to marshal version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to marshal version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt metadata to the version's public key
|
||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||
defer metadataBuffer.Destroy()
|
||||
|
||||
encryptedMetadata, err := EncryptToRecipient(
|
||||
metadataBuffer, versionIdentity.Recipient())
|
||||
if err != nil {
|
||||
Debug("Failed to encrypt version metadata", "error", err, "version", sv.Version)
|
||||
|
||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||
}
|
||||
|
||||
metadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||
Debug("Writing encrypted version metadata", "path", metadataPath)
|
||||
|
||||
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, FilePerms)
|
||||
if err != nil {
|
||||
Debug("Failed to write encrypted version metadata",
|
||||
"error", err, "path", metadataPath)
|
||||
|
||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListVersions lists all versions of a secret
|
||||
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
@@ -409,6 +499,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return []string{}, nil
|
||||
}
|
||||
@@ -420,6 +511,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||
}
|
||||
|
||||
var versions []string
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
versions = append(versions, entry.Name())
|
||||
@@ -456,7 +548,8 @@ func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
||||
_ = fs.Remove(currentPath)
|
||||
|
||||
// Write just the version name to the file
|
||||
if err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms); err != nil {
|
||||
err := afero.WriteFile(fs, currentPath, []byte(version), FilePerms)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current version file: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,22 +32,32 @@
|
||||
// - Long-term key required for all operations
|
||||
// - Concurrent reads handled safely
|
||||
|
||||
package secret
|
||||
package secret_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/secret/internal/secret"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// MockVault implements VaultInterface for testing
|
||||
const (
|
||||
testSecretDir = "/test/secret"
|
||||
testVaultName = "test"
|
||||
testVaultStateDir = "/test"
|
||||
)
|
||||
|
||||
var errNotImplementedInMock = errors.New("not implemented in mock")
|
||||
|
||||
// MockVersionVault implements VaultInterface for testing
|
||||
type MockVersionVault struct {
|
||||
Name string
|
||||
fs afero.Fs
|
||||
@@ -60,31 +70,37 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||
return fmt.Errorf("not implemented in mock")
|
||||
return errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetName() string {
|
||||
return m.Name
|
||||
}
|
||||
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||
return m.fs
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
//nolint:ireturn // implements VaultInterface
|
||||
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||
return nil, fmt.Errorf("not implemented in mock")
|
||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||
_ *memguard.LockedBuffer,
|
||||
) (*secret.PassphraseUnlocker, error) {
|
||||
return nil, errNotImplementedInMock
|
||||
}
|
||||
|
||||
func TestGenerateVersionName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
|
||||
// Test first version generation
|
||||
version1, err := GenerateVersionName(fs, secretDir)
|
||||
version1, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||
|
||||
@@ -94,7 +110,7 @@ func TestGenerateVersionName(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test second version generation on same day
|
||||
version2, err := GenerateVersionName(fs, secretDir)
|
||||
version2, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||
|
||||
@@ -104,8 +120,10 @@ func TestGenerateVersionName(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// Create 999 versions
|
||||
@@ -117,20 +135,22 @@ func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||
}
|
||||
|
||||
// Try to create one more - should fail
|
||||
_, err := GenerateVersionName(fs, secretDir)
|
||||
assert.Error(t, err)
|
||||
_, err := secret.GenerateVersionName(fs, secretDir)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||
}
|
||||
|
||||
func TestNewVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
|
||||
assert.Equal(t, "test/secret", sv.SecretName)
|
||||
assert.Equal(t, "20231215.001", sv.Version)
|
||||
@@ -140,11 +160,13 @@ func TestNewVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionSave(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Create vault directory structure and long-term key
|
||||
@@ -155,18 +177,21 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
// Generate and store long-term public key
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
testValue := []byte("test-secret-value")
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -178,11 +203,13 @@ func TestSecretVersionSave(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
@@ -192,14 +219,16 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version with custom metadata
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
now := time.Now()
|
||||
epochPlusOne := time.Unix(1, 0)
|
||||
sv.Metadata.NotBefore = &epochPlusOne
|
||||
@@ -207,11 +236,12 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
|
||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||
defer testBuffer.Destroy()
|
||||
|
||||
err = sv.Save(testBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create new version object and load metadata
|
||||
sv2 := NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv2 := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
err = sv2.LoadMetadata(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -223,11 +253,13 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSecretVersionGetValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vault := &MockVersionVault{
|
||||
Name: "test",
|
||||
Name: testVaultName,
|
||||
fs: fs,
|
||||
stateDir: "/test",
|
||||
stateDir: testVaultStateDir,
|
||||
}
|
||||
|
||||
// Setup vault with long-term key
|
||||
@@ -237,64 +269,77 @@ func TestSecretVersionGetValue(t *testing.T) {
|
||||
|
||||
ltIdentity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
vault.longTermKey = ltIdentity
|
||||
|
||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
err = afero.WriteFile(
|
||||
fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create and save a version
|
||||
sv := NewVersion(vault, "test/secret", "20231215.001")
|
||||
sv := secret.NewVersion(vault, "test/secret", "20231215.001")
|
||||
originalValue := []byte("test-secret-value-12345")
|
||||
expectedValue := make([]byte, len(originalValue))
|
||||
copy(expectedValue, originalValue)
|
||||
|
||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||
defer originalBuffer.Destroy()
|
||||
|
||||
err = sv.Save(originalBuffer)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Retrieve the value
|
||||
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer retrievedBuffer.Destroy()
|
||||
|
||||
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||
}
|
||||
|
||||
func TestListVersions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
versionsDir := filepath.Join(secretDir, "versions")
|
||||
|
||||
// No versions directory
|
||||
versions, err := ListVersions(fs, secretDir)
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, versions)
|
||||
|
||||
// Create some versions
|
||||
testVersions := []string{"20231215.001", "20231215.002", "20231216.001", "20231214.001"}
|
||||
testVersions := []string{
|
||||
"20231215.001", "20231215.002", "20231216.001", "20231214.001",
|
||||
}
|
||||
for _, v := range testVersions {
|
||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Create a file (not directory) that should be ignored
|
||||
err = afero.WriteFile(fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
err = afero.WriteFile(
|
||||
fs, filepath.Join(versionsDir, "ignore.txt"), []byte("test"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
// List versions
|
||||
versions, err = ListVersions(fs, secretDir)
|
||||
versions, err = secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should be sorted in reverse chronological order
|
||||
expected := []string{"20231216.001", "20231215.002", "20231215.001", "20231214.001"}
|
||||
expected := []string{
|
||||
"20231216.001", "20231215.002", "20231215.001", "20231214.001",
|
||||
}
|
||||
assert.Equal(t, expected, versions)
|
||||
}
|
||||
|
||||
func TestGetCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
|
||||
// The current file contains just the version name
|
||||
currentPath := filepath.Join(secretDir, "current")
|
||||
@@ -304,39 +349,43 @@ func TestGetCurrentVersion(t *testing.T) {
|
||||
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.001", version)
|
||||
}
|
||||
|
||||
func TestSetCurrentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
secretDir := "/test/secret"
|
||||
secretDir := testSecretDir
|
||||
|
||||
err := fs.MkdirAll(secretDir, 0o755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set current version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it was set
|
||||
version, err := GetCurrentVersion(fs, secretDir)
|
||||
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231216.002", version)
|
||||
|
||||
// Update to different version
|
||||
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||
require.NoError(t, err)
|
||||
|
||||
version, err = GetCurrentVersion(fs, secretDir)
|
||||
version, err = secret.GetCurrentVersion(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "20231217.001", version)
|
||||
}
|
||||
|
||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Test that all timestamp fields behave consistently as pointers
|
||||
vm := VersionMetadata{
|
||||
vm := secret.VersionMetadata{
|
||||
ID: "test-id",
|
||||
}
|
||||
|
||||
@@ -368,5 +417,6 @@ func TestVersionMetadataTimestamps(t *testing.T) {
|
||||
// Helper function
|
||||
func fileExists(fs afero.Fs, path string) bool {
|
||||
exists, _ := afero.Exists(fs, path)
|
||||
|
||||
return exists
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user