Update golangci-lint to v2.12.2 with canonical config #29
144
.golangci.yml
144
.golangci.yml
@@ -1,128 +1,34 @@
|
|||||||
version: "2"
|
version: "2"
|
||||||
|
|
||||||
|
# Config schema uses the golangci-lint v2 layout (settings live under
|
||||||
|
# linters.settings, not top-level linters-settings) so that the
|
||||||
|
# thresholds below are actually applied by golangci-lint >= v2.
|
||||||
|
|
||||||
run:
|
run:
|
||||||
go: "1.24"
|
timeout: 5m
|
||||||
tests: false
|
modules-download-mode: readonly
|
||||||
|
|
||||||
linters:
|
linters:
|
||||||
enable:
|
default: all
|
||||||
# Additional linters requested
|
disable:
|
||||||
- testifylint # Checks usage of github.com/stretchr/testify
|
# Genuinely incompatible with project patterns
|
||||||
- usetesting # usetesting is an analyzer that detects using os.Setenv instead of t.Setenv since Go 1.17
|
- exhaustruct # Requires all struct fields
|
||||||
- tagliatelle # Checks the struct tags
|
- depguard # Dependency allow/block lists
|
||||||
- nlreturn # nlreturn checks for a new line before return and branch statements
|
- godot # Requires comments to end with periods
|
||||||
- nilnil # Checks that there is no simultaneous return of nil error and an invalid value
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
- nestif # Reports deeply nested if statements
|
- wrapcheck # Too verbose for internal packages
|
||||||
- mnd # An analyzer to detect magic numbers
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
- lll # Reports long lines
|
settings:
|
||||||
- intrange # intrange is a linter to find places where for loops could make use of an integer range
|
lll:
|
||||||
- gochecknoglobals # Check that no global variables exist
|
line-length: 88
|
||||||
|
funlen:
|
||||||
# Default/existing linters that are commonly useful
|
lines: 80
|
||||||
- govet
|
statements: 50
|
||||||
- errcheck
|
cyclop:
|
||||||
- staticcheck
|
max-complexity: 15
|
||||||
- unused
|
dupl:
|
||||||
- ineffassign
|
threshold: 100
|
||||||
- misspell
|
|
||||||
- revive
|
|
||||||
- gosec
|
|
||||||
- unconvert
|
|
||||||
- unparam
|
|
||||||
|
|
||||||
linters-settings:
|
|
||||||
lll:
|
|
||||||
line-length: 120
|
|
||||||
|
|
||||||
mnd:
|
|
||||||
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description.
|
|
||||||
checks:
|
|
||||||
- argument
|
|
||||||
- case
|
|
||||||
- condition
|
|
||||||
- operation
|
|
||||||
- return
|
|
||||||
- assign
|
|
||||||
ignored-numbers:
|
|
||||||
- '0'
|
|
||||||
- '1'
|
|
||||||
- '2'
|
|
||||||
- '8'
|
|
||||||
- '16'
|
|
||||||
- '40' # GPG fingerprint length
|
|
||||||
- '64'
|
|
||||||
- '128'
|
|
||||||
- '256'
|
|
||||||
- '512'
|
|
||||||
- '1024'
|
|
||||||
- '2048'
|
|
||||||
- '4096'
|
|
||||||
|
|
||||||
nestif:
|
|
||||||
min-complexity: 4
|
|
||||||
|
|
||||||
nlreturn:
|
|
||||||
block-size: 2
|
|
||||||
|
|
||||||
revive:
|
|
||||||
rules:
|
|
||||||
- name: var-naming
|
|
||||||
arguments:
|
|
||||||
- []
|
|
||||||
- []
|
|
||||||
- "upperCaseConst=true"
|
|
||||||
|
|
||||||
tagliatelle:
|
|
||||||
case:
|
|
||||||
rules:
|
|
||||||
json: snake
|
|
||||||
yaml: snake
|
|
||||||
xml: snake
|
|
||||||
bson: snake
|
|
||||||
|
|
||||||
testifylint:
|
|
||||||
enable-all: true
|
|
||||||
|
|
||||||
usetesting: {}
|
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
max-same-issues: 0
|
max-same-issues: 0
|
||||||
exclude-rules:
|
|
||||||
- path: ".*_gen\\.go"
|
|
||||||
linters:
|
|
||||||
- lll
|
|
||||||
|
|
||||||
# Exclude unused parameter warnings for cobra command signatures
|
|
||||||
- text: "parameter '(args|cmd)' seems to be unused"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Allow ALL_CAPS constant names
|
|
||||||
- text: "don't use ALL_CAPS in Go names"
|
|
||||||
linters:
|
|
||||||
- revive
|
|
||||||
|
|
||||||
# Exclude all linters for internal/macse directory
|
|
||||||
- path: "internal/macse/.*"
|
|
||||||
linters:
|
|
||||||
- errcheck
|
|
||||||
- lll
|
|
||||||
- mnd
|
|
||||||
- nestif
|
|
||||||
- nlreturn
|
|
||||||
- revive
|
|
||||||
- unconvert
|
|
||||||
- govet
|
|
||||||
- staticcheck
|
|
||||||
- unused
|
|
||||||
- ineffassign
|
|
||||||
- misspell
|
|
||||||
- gosec
|
|
||||||
- unparam
|
|
||||||
- testifylint
|
|
||||||
- usetesting
|
|
||||||
- tagliatelle
|
|
||||||
- nilnil
|
|
||||||
- intrange
|
|
||||||
- gochecknoglobals
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
# golangci/golangci-lint v2.1.6 (2026-03-10)
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
|
|||||||
25
TODO.md
25
TODO.md
@@ -25,6 +25,20 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
|
||||||
|
`.golangci.yml` (all linters enabled minus the standard disable
|
||||||
|
list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage
|
||||||
|
image to the tagged v2.12.2 Debian digest; fixed all ~1550 new
|
||||||
|
findings across `internal/` and `pkg/` (line wrapping, `wsl_v5`
|
||||||
|
blank lines, sentinel errors for `err113`, `t.Parallel()` where
|
||||||
|
safe, `_test` package conversions, complexity/`dupl` helper
|
||||||
|
extraction) on branch `golangci-v2.12.2`. Reworked after review:
|
||||||
|
the `err113` sentinels in `internal/vault`, `internal/secret`,
|
||||||
|
`internal/cli` and `pkg/bip85` were reshaped so every composed
|
||||||
|
error message is byte-identical to `main`, and
|
||||||
|
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||||
|
skips an unreadable `unlockers.d` entry with a warning instead of
|
||||||
|
emitting a fabricated fallback ID.
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||||
@@ -50,6 +64,17 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
- Compliance (after Next Step lands): keep main green under the new
|
- Compliance (after Next Step lands): keep main green under the new
|
||||||
.gitea workflow; run make check before every merge.
|
.gitea workflow; run make check before every merge.
|
||||||
|
- Implement version-number shell completion for the second arg of
|
||||||
|
`secret version promote` and `secret version rm`
|
||||||
|
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
||||||
|
- Cover mnemonic-vs-xprv identity consistency in
|
||||||
|
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
|
||||||
|
in-code FIXME removed for godox).
|
||||||
|
- Darwin-gated files (`internal/secret/keychainunlocker.go`,
|
||||||
|
`seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related
|
||||||
|
tests) are not linted on the Linux CI runner and still contain lines
|
||||||
|
over the new 88-column limit; they will surface if lint ever runs on
|
||||||
|
macOS.
|
||||||
- Merge secure-enclave-unlocker to main once review is done.
|
- Merge secure-enclave-unlocker to main once review is done.
|
||||||
- 1.0 critical security blockers (from repo TODO.md):
|
- 1.0 critical security blockers (from repo TODO.md):
|
||||||
- Command injection: GPG key IDs passed unescaped to exec.Command
|
- Command injection: GPG key IDs passed unescaped to exec.Command
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type Instance struct {
|
|||||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||||
func NewCLIInstance() (*Instance, error) {
|
func NewCLIInstance() (*Instance, error) {
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
stateDir, err := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
return nil, fmt.Errorf("cannot determine state directory: %w", err)
|
||||||
@@ -30,7 +31,8 @@ func NewCLIInstance() (*Instance, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing)
|
// NewCLIInstanceWithFs creates a new CLI instance with the given
|
||||||
|
// filesystem (for testing)
|
||||||
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
||||||
stateDir, err := secret.DetermineStateDir("")
|
stateDir, err := secret.DetermineStateDir("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,7 +45,8 @@ func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state directory (for testing)
|
// NewCLIInstanceWithStateDir creates a new CLI instance with custom state
|
||||||
|
// directory (for testing)
|
||||||
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
func NewCLIInstanceWithStateDir(fs afero.Fs, stateDir string) *Instance {
|
||||||
return &Instance{
|
return &Instance{
|
||||||
fs: fs,
|
fs: fs,
|
||||||
@@ -67,6 +70,6 @@ func (cli *Instance) GetStateDir() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Print outputs to the command's configured output writer
|
// Print outputs to the command's configured output writer
|
||||||
func (cli *Instance) Print(a ...interface{}) (n int, err error) {
|
func (cli *Instance) Print(a ...any) (int, error) {
|
||||||
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,43 @@
|
|||||||
package cli
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCLIInstanceStateDir(t *testing.T) {
|
func TestCLIInstanceStateDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Test the CLI instance state directory functionality
|
// Test the CLI instance state directory functionality
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create a test state directory
|
// Create a test state directory
|
||||||
testStateDir := "/test-state-dir"
|
testStateDir := "/test-state-dir"
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
instance := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
|
||||||
if cli.GetStateDir() != testStateDir {
|
got := instance.GetStateDir()
|
||||||
t.Errorf("Expected state directory %q, got %q", testStateDir, cli.GetStateDir())
|
if got != testStateDir {
|
||||||
|
t.Errorf("Expected state directory %q, got %q", testStateDir, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // reads process environment to determine the state dir
|
||||||
func TestCLIInstanceWithFs(t *testing.T) {
|
func TestCLIInstanceWithFs(t *testing.T) {
|
||||||
// Test creating CLI instance with custom filesystem
|
// Test creating CLI instance with custom filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
cli, err := NewCLIInstanceWithFs(fs)
|
|
||||||
|
instance, err := cli.NewCLIInstanceWithFs(fs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The state directory should be determined automatically
|
// The state directory should be determined automatically
|
||||||
stateDir := cli.GetStateDir()
|
stateDir := instance.GetStateDir()
|
||||||
if stateDir == "" {
|
if stateDir == "" {
|
||||||
t.Error("Expected non-empty state directory")
|
t.Error("Expected non-empty state directory")
|
||||||
}
|
}
|
||||||
@@ -48,6 +54,7 @@ func TestDetermineStateDir(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if stateDir != testEnvDir {
|
if stateDir != testEnvDir {
|
||||||
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
t.Errorf("Expected state directory %q from environment, got %q", testEnvDir, stateDir)
|
||||||
}
|
}
|
||||||
@@ -55,12 +62,15 @@ func TestDetermineStateDir(t *testing.T) {
|
|||||||
// Test with custom config dir
|
// Test with custom config dir
|
||||||
_ = os.Unsetenv(secret.EnvStateDir)
|
_ = os.Unsetenv(secret.EnvStateDir)
|
||||||
customConfigDir := "/custom-config"
|
customConfigDir := "/custom-config"
|
||||||
|
|
||||||
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
stateDir, err = secret.DetermineStateDir(customConfigDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
expectedDir := filepath.Join(customConfigDir, secret.AppID)
|
||||||
if stateDir != expectedDir {
|
if stateDir != expectedDir {
|
||||||
t.Errorf("Expected state directory %q with custom config, got %q", expectedDir, stateDir)
|
t.Errorf("Expected state directory %q with custom config, got %q",
|
||||||
|
expectedDir, stateDir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errUnsupportedShell is returned for unknown shell completion targets
|
||||||
|
var errUnsupportedShell = errors.New("unsupported shell type")
|
||||||
|
|
||||||
func newCompletionCmd() *cobra.Command {
|
func newCompletionCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "completion [bash|zsh|fish|powershell]",
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
@@ -55,7 +59,7 @@ PowerShell:
|
|||||||
case "powershell":
|
case "powershell":
|
||||||
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported shell type: %s", args[0])
|
return fmt.Errorf("%w: %s", errUnsupportedShell, args[0])
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,11 +10,14 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getSecretNamesCompletionFunc returns a completion function that provides secret names
|
// getSecretNamesCompletionFunc returns a completion function that provides
|
||||||
|
// secret names
|
||||||
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(
|
||||||
|
_ *cobra.Command, _ []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,6 +32,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
|
|
||||||
// Filter secrets based on what user has typed
|
// Filter secrets based on what user has typed
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
for _, secret := range secrets {
|
for _, secret := range secrets {
|
||||||
if strings.HasPrefix(secret, toComplete) {
|
if strings.HasPrefix(secret, toComplete) {
|
||||||
completions = append(completions, secret)
|
completions = append(completions, secret)
|
||||||
@@ -40,11 +43,14 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getUnlockerIDsCompletionFunc returns a completion function that provides unlocker IDs
|
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||||
|
// unlocker IDs
|
||||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(
|
||||||
|
_ *cobra.Command, _ []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -66,61 +72,24 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
// Collect unlocker IDs
|
// Collect unlocker IDs
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, metadata := range unlockerMetadataList {
|
||||||
// Get the actual unlocker ID by creating the unlocker instance
|
// Get the actual unlocker ID by creating the unlocker instance
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
id, err := findUnlockerIDByMetadata(
|
||||||
files, err := afero.ReadDir(fs, unlockersDir)
|
fs, unlockersDir, metadata, false,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlockers directory during completion", "error", err)
|
secret.Warn(
|
||||||
|
"Could not read unlockers directory during completion, "+
|
||||||
|
"skipping unlocker",
|
||||||
|
"unlockers_dir", unlockersDir, "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
if id != "" && strings.HasPrefix(id, toComplete) {
|
||||||
if !file.IsDir() {
|
completions = append(completions, id)
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
// Check if this is the right unlocker by comparing metadata
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read unlocker metadata during completion", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
|
||||||
secret.Warn("Could not parse unlocker metadata during completion", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match by type and creation time
|
|
||||||
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
||||||
// Create the appropriate unlocker instance
|
|
||||||
var unlocker secret.Unlocker
|
|
||||||
switch metadata.Type {
|
|
||||||
case "passphrase":
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, diskMetadata)
|
|
||||||
case "keychain":
|
|
||||||
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, diskMetadata)
|
|
||||||
case "pgp":
|
|
||||||
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, diskMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
if unlocker != nil {
|
|
||||||
id := unlocker.GetID()
|
|
||||||
if strings.HasPrefix(id, toComplete) {
|
|
||||||
completions = append(completions, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,17 +97,21 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVaultNamesCompletionFunc returns a completion function that provides vault names
|
// getVaultNamesCompletionFunc returns a completion function that provides
|
||||||
|
// vault names
|
||||||
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(
|
||||||
|
_ *cobra.Command, _ []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
vaults, err := vault.ListVaults(fs, stateDir)
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
for _, v := range vaults {
|
for _, v := range vaults {
|
||||||
if strings.HasPrefix(v, toComplete) {
|
if strings.HasPrefix(v, toComplete) {
|
||||||
completions = append(completions, v)
|
completions = append(completions, v)
|
||||||
@@ -149,57 +122,81 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// getVaultSecretCompletionFunc returns a completion function for vault:secret format
|
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||||
// It completes vault names with ":" suffix, and after ":" it completes secrets from that vault
|
// colon is present in the input
|
||||||
|
func completeVaultQualifiedSecrets(
|
||||||
|
fs afero.Fs, stateDir, toComplete string,
|
||||||
|
) []string {
|
||||||
|
var completions []string
|
||||||
|
|
||||||
|
// Complete secret names for the specified vault
|
||||||
|
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
||||||
|
vaultName := parts[0]
|
||||||
|
secretPrefix := parts[1]
|
||||||
|
|
||||||
|
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||||
|
|
||||||
|
secrets, err := vlt.ListSecrets()
|
||||||
|
if err == nil {
|
||||||
|
for _, secretName := range secrets {
|
||||||
|
if strings.HasPrefix(secretName, secretPrefix) {
|
||||||
|
completions = append(completions, vaultName+":"+secretName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeUnqualifiedVaultSecrets completes vault names (with a ":"
|
||||||
|
// suffix) and secrets from the current vault
|
||||||
|
func completeUnqualifiedVaultSecrets(
|
||||||
|
fs afero.Fs, stateDir, toComplete string,
|
||||||
|
) []string {
|
||||||
|
var completions []string
|
||||||
|
|
||||||
|
// Complete vault names with ":" suffix
|
||||||
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, v := range vaults {
|
||||||
|
if strings.HasPrefix(v, toComplete) {
|
||||||
|
completions = append(completions, v+":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also complete secrets from current vault (for within-vault moves)
|
||||||
|
currentVlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
if err == nil {
|
||||||
|
secrets, err := currentVlt.ListSecrets()
|
||||||
|
if err == nil {
|
||||||
|
for _, secretName := range secrets {
|
||||||
|
if strings.HasPrefix(secretName, toComplete) {
|
||||||
|
completions = append(completions, secretName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return completions
|
||||||
|
}
|
||||||
|
|
||||||
|
// getVaultSecretCompletionFunc returns a completion function for the
|
||||||
|
// vault:secret format. It completes vault names with ":" suffix, and
|
||||||
|
// after ":" it completes secrets from that vault.
|
||||||
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(
|
||||||
var completions []string
|
_ *cobra.Command, _ []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
// Check if we're completing after a vault: prefix
|
// Check if we're completing after a vault: prefix
|
||||||
if strings.Contains(toComplete, ":") {
|
if strings.Contains(toComplete, ":") {
|
||||||
// Complete secret names for the specified vault
|
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
||||||
const vaultSecretParts = 2
|
cobra.ShellCompDirectiveNoFileComp
|
||||||
parts := strings.SplitN(toComplete, ":", vaultSecretParts)
|
|
||||||
vaultName := parts[0]
|
|
||||||
secretPrefix := parts[1]
|
|
||||||
|
|
||||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
|
||||||
secrets, err := vlt.ListSecrets()
|
|
||||||
if err == nil {
|
|
||||||
for _, secretName := range secrets {
|
|
||||||
if strings.HasPrefix(secretName, secretPrefix) {
|
|
||||||
completions = append(completions, vaultName+":"+secretName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return completions, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete vault names with ":" suffix
|
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
|
||||||
vaults, err := vault.ListVaults(fs, stateDir)
|
cobra.ShellCompDirectiveNoSpace
|
||||||
if err == nil {
|
|
||||||
for _, v := range vaults {
|
|
||||||
if strings.HasPrefix(v, toComplete) {
|
|
||||||
completions = append(completions, v+":")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also complete secrets from current vault (for within-vault moves)
|
|
||||||
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil {
|
|
||||||
secrets, err := currentVlt.ListSecrets()
|
|
||||||
if err == nil {
|
|
||||||
for _, secretName := range secrets {
|
|
||||||
if strings.HasPrefix(secretName, toComplete) {
|
|
||||||
completions = append(completions, secretName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return completions, cobra.ShellCompDirectiveNoSpace
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,11 +13,22 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newEncryptCmd() *cobra.Command {
|
// Sentinel errors for encrypt/decrypt operations
|
||||||
|
var (
|
||||||
|
errNotAgeSecretKey = errors.New(
|
||||||
|
"does not contain a valid age secret key")
|
||||||
|
errSecretDoesNotExist = errors.New("does not exist")
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCryptoCmd builds an encrypt/decrypt command with input/output flags
|
||||||
|
func newCryptoCmd(
|
||||||
|
use, short, long string,
|
||||||
|
run func(cli *Instance, secretName, inputFile, outputFile string) error,
|
||||||
|
) *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "encrypt <secret-name>",
|
Use: use,
|
||||||
Short: "Encrypt data using an age secret key stored in a secret",
|
Short: short,
|
||||||
Long: `Encrypt data using an age secret key. If the secret doesn't exist, a new age key is generated and stored.`,
|
Long: long,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
inputFile, _ := cmd.Flags().GetString("input")
|
inputFile, _ := cmd.Flags().GetString("input")
|
||||||
@@ -26,9 +38,10 @@ func newEncryptCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
return cli.Encrypt(args[0], inputFile, outputFile)
|
return run(cli, args[0], inputFile, outputFile)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,30 +51,73 @@ func newEncryptCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newEncryptCmd() *cobra.Command {
|
||||||
|
return newCryptoCmd(
|
||||||
|
"encrypt <secret-name>",
|
||||||
|
"Encrypt data using an age secret key stored in a secret",
|
||||||
|
"Encrypt data using an age secret key. If the secret doesn't "+
|
||||||
|
"exist, a new age key is generated and stored.",
|
||||||
|
(*Instance).Encrypt,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func newDecryptCmd() *cobra.Command {
|
func newDecryptCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
return newCryptoCmd(
|
||||||
Use: "decrypt <secret-name>",
|
"decrypt <secret-name>",
|
||||||
Short: "Decrypt data using an age secret key stored in a secret",
|
"Decrypt data using an age secret key stored in a secret",
|
||||||
Long: `Decrypt data using an age secret key stored in the specified secret.`,
|
"Decrypt data using an age secret key stored in the specified secret.",
|
||||||
Args: cobra.ExactArgs(1),
|
(*Instance).Decrypt,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
)
|
||||||
inputFile, _ := cmd.Flags().GetString("input")
|
}
|
||||||
outputFile, _ := cmd.Flags().GetString("output")
|
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
// resolveEncryptionKey returns a secure buffer holding the age secret key
|
||||||
if err != nil {
|
// for the named secret, generating and storing a new key if the secret
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
// does not exist. The caller must destroy the returned buffer.
|
||||||
}
|
func (cli *Instance) resolveEncryptionKey(
|
||||||
cli.cmd = cmd
|
vlt *vault.Vault, secretName string,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
|
// Check if secret exists
|
||||||
|
secretObj := secret.NewSecret(vlt, secretName)
|
||||||
|
|
||||||
return cli.Decrypt(args[0], inputFile, outputFile)
|
exists, err := secretObj.Exists()
|
||||||
},
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)")
|
if !exists {
|
||||||
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)")
|
// Secret doesn't exist, generate new age key and store it
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return cmd
|
// Store the generated key directly in a secure buffer
|
||||||
|
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||||
|
if err != nil {
|
||||||
|
secureBuffer.Destroy()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to store age key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secureBuffer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret exists, get the age secret key from it
|
||||||
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that it's a valid age secret key
|
||||||
|
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||||
|
secretBuffer.Destroy()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return secretBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt encrypts data using an age secret key stored in a secret
|
// Encrypt encrypts data using an age secret key stored in a secret
|
||||||
@@ -72,55 +128,15 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var ageSecretKey string
|
// Get or create the age secret key for this secret
|
||||||
|
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||||
// Check if secret exists
|
|
||||||
secretObj := secret.NewSecret(vlt, secretName)
|
|
||||||
exists, err := secretObj.Exists()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
defer keyBuffer.Destroy()
|
||||||
|
|
||||||
if !exists { //nolint:nestif // Clear conditional logic for secret generation vs retrieval
|
// Parse the secret key
|
||||||
// Secret doesn't exist, generate new age key and store it
|
identity, err := age.ParseX25519Identity(keyBuffer.String())
|
||||||
identity, err := age.GenerateX25519Identity()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to generate age key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the generated key directly in a secure buffer
|
|
||||||
identityStr := identity.String()
|
|
||||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identityStr))
|
|
||||||
defer secureBuffer.Destroy()
|
|
||||||
|
|
||||||
// Set ageSecretKey for later use (we need it for encryption)
|
|
||||||
ageSecretKey = identityStr
|
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to store age key: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Secret exists, get the age secret key from it
|
|
||||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
|
||||||
}
|
|
||||||
defer secretBuffer.Destroy()
|
|
||||||
|
|
||||||
ageSecretKey = secretBuffer.String()
|
|
||||||
|
|
||||||
// Validate that it's a valid age secret key
|
|
||||||
if !isValidAgeSecretKey(ageSecretKey) {
|
|
||||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the secret key using secure buffer
|
|
||||||
finalSecureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
|
|
||||||
defer finalSecureBuffer.Destroy()
|
|
||||||
|
|
||||||
identity, err := age.ParseX25519Identity(finalSecureBuffer.String())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse age secret key: %w", err)
|
return fmt.Errorf("failed to parse age secret key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -130,23 +146,27 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Set up input reader
|
// Set up input reader
|
||||||
var input io.Reader = os.Stdin
|
var input io.Reader = os.Stdin
|
||||||
|
|
||||||
if inputFile != "" {
|
if inputFile != "" {
|
||||||
file, err := cli.fs.Open(inputFile)
|
file, err := cli.fs.Open(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
return fmt.Errorf("failed to open input file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
// Set up output writer
|
||||||
output := cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
file, err := cli.fs.Create(outputFile)
|
file, err := cli.fs.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
output = file
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,11 +176,13 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to create age encryptor: %w", err)
|
return fmt.Errorf("failed to create age encryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.Copy(encryptor, input); err != nil {
|
_, err = io.Copy(encryptor, input)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt data: %w", err)
|
return fmt.Errorf("failed to encrypt data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := encryptor.Close(); err != nil {
|
err = encryptor.Close()
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to finalize encryption: %w", err)
|
return fmt.Errorf("failed to finalize encryption: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,26 +199,18 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
secretObj := secret.NewSecret(vlt, secretName)
|
secretObj := secret.NewSecret(vlt, secretName)
|
||||||
|
|
||||||
exists, err := secretObj.Exists()
|
exists, err := secretObj.Exists()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' does not exist", secretName)
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the age secret key from the secret
|
// Get the age secret key from the secret
|
||||||
var secretBuffer *memguard.LockedBuffer
|
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
|
||||||
secretBuffer, err = secretObj.GetValue(nil)
|
|
||||||
} else {
|
|
||||||
unlocker, unlockErr := vlt.GetCurrentUnlocker()
|
|
||||||
if unlockErr != nil {
|
|
||||||
return fmt.Errorf("failed to get current unlocker: %w", unlockErr)
|
|
||||||
}
|
|
||||||
secretBuffer, err = secretObj.GetValue(unlocker)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
@@ -204,7 +218,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Validate that it's a valid age secret key
|
// Validate that it's a valid age secret key
|
||||||
if !isValidAgeSecretKey(secretBuffer.String()) {
|
if !isValidAgeSecretKey(secretBuffer.String()) {
|
||||||
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
|
return fmt.Errorf("secret '%s' %w", secretName, errNotAgeSecretKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the age secret key to get the identity
|
// Parse the age secret key to get the identity
|
||||||
@@ -215,23 +229,27 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
|
|
||||||
// Set up input reader
|
// Set up input reader
|
||||||
var input io.Reader = os.Stdin
|
var input io.Reader = os.Stdin
|
||||||
|
|
||||||
if inputFile != "" {
|
if inputFile != "" {
|
||||||
file, err := cli.fs.Open(inputFile)
|
file, err := cli.fs.Open(inputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open input file: %w", err)
|
return fmt.Errorf("failed to open input file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
input = file
|
input = file
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up output writer
|
// Set up output writer
|
||||||
output := cli.cmd.OutOrStdout()
|
output := cli.cmd.OutOrStdout()
|
||||||
|
|
||||||
if outputFile != "" {
|
if outputFile != "" {
|
||||||
file, err := cli.fs.Create(outputFile)
|
file, err := cli.fs.Create(outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create output file: %w", err)
|
return fmt.Errorf("failed to create output file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
defer func() { _ = file.Close() }()
|
||||||
|
|
||||||
output = file
|
output = file
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,22 +259,27 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return fmt.Errorf("failed to create age decryptor: %w", err)
|
return fmt.Errorf("failed to create age decryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := io.Copy(output, decryptor); err != nil {
|
_, err = io.Copy(output, decryptor)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to decrypt data: %w", err)
|
return fmt.Errorf("failed to decrypt data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidAgeSecretKey checks if a string is a valid age secret key by attempting to parse it
|
// isValidAgeSecretKey checks if a string is a valid age secret key by
|
||||||
|
// attempting to parse it
|
||||||
func isValidAgeSecretKey(key string) bool {
|
func isValidAgeSecretKey(key string) bool {
|
||||||
_, err := age.ParseX25519Identity(key)
|
_, err := age.ParseX25519Identity(key)
|
||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSecretValue retrieves the value of a secret using the appropriate unlocker
|
// getSecretValue retrieves the value of a secret using the appropriate
|
||||||
func (cli *Instance) getSecretValue(vlt *vault.Vault, secretObj *secret.Secret) (*memguard.LockedBuffer, error) {
|
// unlocker
|
||||||
|
func (cli *Instance) getSecretValue(
|
||||||
|
vlt *vault.Vault, secretObj *secret.Secret,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
if os.Getenv(secret.EnvMnemonic) != "" {
|
||||||
return secretObj.GetValue(nil)
|
return secretObj.GetValue(nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,6 +18,16 @@ const (
|
|||||||
mnemonicEntropyBits = 128
|
mnemonicEntropyBits = 128
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for secret generation
|
||||||
|
var (
|
||||||
|
errLengthTooSmall = errors.New("length must be at least 1")
|
||||||
|
errLengthNotPositive = errors.New("length must be positive")
|
||||||
|
errMnemonicTypeNotSupported = errors.New(
|
||||||
|
"mnemonic type not supported for secret generation, " +
|
||||||
|
"use 'secret generate mnemonic' instead")
|
||||||
|
errUnsupportedSecretType = errors.New("unsupported type")
|
||||||
|
)
|
||||||
|
|
||||||
func newGenerateCmd() *cobra.Command {
|
func newGenerateCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "generate",
|
Use: "generate",
|
||||||
@@ -52,8 +63,9 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "secret <name>",
|
Use: "secret <name>",
|
||||||
Short: "Generate a random secret and store it in the vault",
|
Short: "Generate a random secret and store it in the vault",
|
||||||
Long: `Generate a cryptographically secure random secret and store it in the current vault under the given name.`,
|
Long: `Generate a cryptographically secure random secret and ` +
|
||||||
Args: cobra.ExactArgs(1),
|
`store it in the current vault under the given name.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
length, _ := cmd.Flags().GetInt("length")
|
length, _ := cmd.Flags().GetInt("length")
|
||||||
secretType, _ := cmd.Flags().GetString("type")
|
secretType, _ := cmd.Flags().GetString("type")
|
||||||
@@ -68,8 +80,10 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().IntP("length", "l", defaultSecretLength, "Length of the generated secret (default 16)")
|
cmd.Flags().IntP("length", "l", defaultSecretLength,
|
||||||
cmd.Flags().StringP("type", "t", "base58", "Type of secret to generate (base58, alnum)")
|
"Length of the generated secret (default 16)")
|
||||||
|
cmd.Flags().StringP("type", "t", "base58",
|
||||||
|
"Type of secret to generate (base58, alnum)")
|
||||||
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
cmd.Flags().BoolP("force", "f", false, "Overwrite existing secret")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -98,7 +112,8 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
|||||||
fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely")
|
fmt.Fprintln(os.Stderr, " • Write it down on paper and store it safely")
|
||||||
fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone")
|
fmt.Fprintln(os.Stderr, " • Do not store it digitally or share it with anyone")
|
||||||
fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets")
|
fmt.Fprintln(os.Stderr, " • You will need this phrase to recover your secrets")
|
||||||
fmt.Fprintln(os.Stderr, " • If you lose this phrase, your secrets cannot be recovered")
|
fmt.Fprintln(os.Stderr,
|
||||||
|
" • If you lose this phrase, your secrets cannot be recovered")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||||
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||||
@@ -116,11 +131,13 @@ func (cli *Instance) GenerateSecret(
|
|||||||
force bool,
|
force bool,
|
||||||
) error {
|
) error {
|
||||||
if length < 1 {
|
if length < 1 {
|
||||||
return fmt.Errorf("length must be at least 1")
|
return errLengthTooSmall
|
||||||
}
|
}
|
||||||
|
|
||||||
var secretValue string
|
var (
|
||||||
var err error
|
secretValue string
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
|
||||||
switch secretType {
|
switch secretType {
|
||||||
case "base58":
|
case "base58":
|
||||||
@@ -128,9 +145,10 @@ func (cli *Instance) GenerateSecret(
|
|||||||
case "alnum":
|
case "alnum":
|
||||||
secretValue, err = generateRandomAlnum(length)
|
secretValue, err = generateRandomAlnum(length)
|
||||||
case "mnemonic":
|
case "mnemonic":
|
||||||
return fmt.Errorf("mnemonic type not supported for secret generation, use 'secret generate mnemonic' instead")
|
return errMnemonicTypeNotSupported
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported type: %s (supported: base58, alnum)", secretType)
|
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
||||||
|
errUnsupportedSecretType, secretType)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -147,11 +165,13 @@ func (cli *Instance) GenerateSecret(
|
|||||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||||
defer secretBuffer.Destroy()
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
if err := vlt.AddSecret(secretName, secretBuffer, force); err != nil {
|
err = vlt.AddSecret(secretName, secretBuffer, force)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Generated and stored %d-character %s secret: %s\n", length, secretType, secretName)
|
cmd.Printf("Generated and stored %d-character %s secret: %s\n",
|
||||||
|
length, secretType, secretName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -170,10 +190,11 @@ func generateRandomAlnum(length int) (string, error) {
|
|||||||
return generateRandomString(length, alnumChars)
|
return generateRandomString(length, alnumChars)
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomString generates a random string of the specified length using the given character set
|
// generateRandomString generates a random string of the specified length
|
||||||
|
// using the given character set
|
||||||
func generateRandomString(length int, charset string) (string, error) {
|
func generateRandomString(length int, charset string) (string, error) {
|
||||||
if length <= 0 {
|
if length <= 0 {
|
||||||
return "", fmt.Errorf("length must be positive")
|
return "", errLengthNotPositive
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]byte, length)
|
result := make([]byte, length)
|
||||||
@@ -184,6 +205,7 @@ func generateRandomString(length int, charset string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to generate random number: %w", err)
|
return "", fmt.Errorf("failed to generate random number: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result[i] = charset[randomIndex.Int64()]
|
result[i] = charset[randomIndex.Int64()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version info - these are set at build time
|
// Version info - these are set at build time
|
||||||
var ( //nolint:gochecknoglobals // Set at build time
|
var (
|
||||||
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
Version = "dev" //nolint:gochecknoglobals // Set at build time
|
||||||
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
|
GitCommit = "unknown" //nolint:gochecknoglobals // Set at build time
|
||||||
)
|
)
|
||||||
@@ -35,8 +35,8 @@ type InfoOutput struct {
|
|||||||
NumVaults int `json:"numVaults"`
|
NumVaults int `json:"numVaults"`
|
||||||
NumSecrets int `json:"numSecrets"`
|
NumSecrets int `json:"numSecrets"`
|
||||||
TotalSize int64 `json:"totalSizeBytes"`
|
TotalSize int64 `json:"totalSizeBytes"`
|
||||||
OldestSecret time.Time `json:"oldestSecret,omitempty"`
|
OldestSecret time.Time `json:"oldestSecret"`
|
||||||
LatestSecret time.Time `json:"latestSecret,omitempty"`
|
LatestSecret time.Time `json:"latestSecret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// newInfoCmd returns the info command
|
// newInfoCmd returns the info command
|
||||||
@@ -51,7 +51,8 @@ func newInfoCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "info",
|
Use: "info",
|
||||||
Short: "Display system information",
|
Short: "Display system information",
|
||||||
Long: "Display information about the secret system including version, vault statistics, and storage usage",
|
Long: "Display information about the secret system including " +
|
||||||
|
"version, vault statistics, and storage usage",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
return cli.Info(cmd, jsonOutput)
|
return cli.Info(cmd, jsonOutput)
|
||||||
},
|
},
|
||||||
@@ -81,6 +82,7 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
|
|
||||||
// Count vaults
|
// Count vaults
|
||||||
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
vaultsDir := filepath.Join(cli.stateDir, "vaults.d")
|
||||||
|
|
||||||
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
vaultEntries, err := afero.ReadDir(cli.fs, vaultsDir)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, entry := range vaultEntries {
|
for _, entry := range vaultEntries {
|
||||||
@@ -92,12 +94,15 @@ func (cli *Instance) Info(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
|
|
||||||
// Gather statistics from all vaults
|
// Gather statistics from all vaults
|
||||||
if info.NumVaults > 0 {
|
if info.NumVaults > 0 {
|
||||||
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(cli.fs, vaultsDir)
|
totalSecrets, totalSize, oldestTime, latestTime, _ := gatherVaultStats(
|
||||||
|
cli.fs, vaultsDir)
|
||||||
info.NumSecrets = totalSecrets
|
info.NumSecrets = totalSecrets
|
||||||
info.TotalSize = totalSize
|
info.TotalSize = totalSize
|
||||||
|
|
||||||
if !oldestTime.IsZero() {
|
if !oldestTime.IsZero() {
|
||||||
info.OldestSecret = oldestTime
|
info.OldestSecret = oldestTime
|
||||||
}
|
}
|
||||||
|
|
||||||
if !latestTime.IsZero() {
|
if !latestTime.IsZero() {
|
||||||
info.LatestSecret = latestTime
|
info.LatestSecret = latestTime
|
||||||
}
|
}
|
||||||
@@ -144,19 +149,24 @@ func prettyPrintInfo(w io.Writer, info InfoOutput) error {
|
|||||||
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
_, _ = fmt.Fprintln(w, strings.Repeat("─", separatorLength))
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
_, _ = fmt.Fprintf(w, "🗂️ Vaults: %s\n", bold.Sprint(info.NumVaults))
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
_, _ = fmt.Fprintf(w, "🔑 Secrets: %s\n", bold.Sprint(info.NumSecrets))
|
||||||
|
|
||||||
if info.TotalSize >= 0 {
|
if info.TotalSize >= 0 {
|
||||||
//nolint:gosec // TotalSize is always >= 0
|
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n",
|
||||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
bold.Sprint(humanize.Bytes(uint64(info.TotalSize))))
|
||||||
} else {
|
} else {
|
||||||
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
_, _ = fmt.Fprintf(w, "💾 Total Size: %s\n", bold.Sprint("0 B"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.OldestSecret.IsZero() {
|
if !info.OldestSecret.IsZero() {
|
||||||
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n", info.OldestSecret.Format("2006-01-02 15:04:05"))
|
_, _ = fmt.Fprintf(w, "🕰️ Oldest Secret: %s\n",
|
||||||
|
info.OldestSecret.Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
if !info.LatestSecret.IsZero() {
|
if !info.LatestSecret.IsZero() {
|
||||||
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n", info.LatestSecret.Format("2006-01-02 15:04:05"))
|
_, _ = fmt.Fprintf(w, "✨ Latest Secret: %s\n",
|
||||||
|
info.LatestSecret.Format("2006-01-02 15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _ = fmt.Fprintln(w)
|
_, _ = fmt.Fprintln(w)
|
||||||
|
|||||||
@@ -8,81 +8,115 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// gatherVaultStats collects statistics from all vaults
|
// vaultStats accumulates statistics while walking vault directories
|
||||||
|
type vaultStats struct {
|
||||||
|
totalSecrets int
|
||||||
|
totalSize int64
|
||||||
|
oldestTime time.Time
|
||||||
|
latestTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// addVersion accumulates size and timestamp info for one version directory
|
||||||
|
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
||||||
|
// Add size of encrypted data
|
||||||
|
dataPath := filepath.Join(versionPath, "data.age")
|
||||||
|
|
||||||
|
stat, err := fs.Stat(dataPath)
|
||||||
|
if err == nil {
|
||||||
|
s.totalSize += stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add size of metadata
|
||||||
|
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||||
|
|
||||||
|
stat, err = fs.Stat(metaPath)
|
||||||
|
if err == nil {
|
||||||
|
s.totalSize += stat.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track timestamps
|
||||||
|
stat, err = fs.Stat(versionPath)
|
||||||
|
if err == nil {
|
||||||
|
modTime := stat.ModTime()
|
||||||
|
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
||||||
|
s.oldestTime = modTime
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.latestTime.IsZero() || modTime.After(s.latestTime) {
|
||||||
|
s.latestTime = modTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSecret accumulates stats for one secret directory
|
||||||
|
func (s *vaultStats) addSecret(fs afero.Fs, secretsPath, secretName string) {
|
||||||
|
s.totalSecrets++
|
||||||
|
secretPath := filepath.Join(secretsPath, secretName)
|
||||||
|
|
||||||
|
// Get size and timestamps from all versions
|
||||||
|
versionsPath := filepath.Join(secretPath, "versions")
|
||||||
|
|
||||||
|
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Could not read versions directory for secret",
|
||||||
|
"secret", secretName, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, versionEntry := range versionEntries {
|
||||||
|
if !versionEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.addVersion(fs, filepath.Join(versionsPath, versionEntry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addVault accumulates stats for one vault directory
|
||||||
|
func (s *vaultStats) addVault(fs afero.Fs, vaultsDir, vaultName string) {
|
||||||
|
vaultPath := filepath.Join(vaultsDir, vaultName)
|
||||||
|
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
||||||
|
|
||||||
|
// Count secrets in this vault
|
||||||
|
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Could not read secrets directory for vault",
|
||||||
|
"vault", vaultName, "error", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, secretEntry := range secretEntries {
|
||||||
|
if !secretEntry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
s.addSecret(fs, secretsPath, secretEntry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gatherVaultStats collects statistics from all vaults, returning the
|
||||||
|
// total secret count, total size, and oldest/latest secret timestamps
|
||||||
func gatherVaultStats(
|
func gatherVaultStats(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
vaultsDir string,
|
vaultsDir string,
|
||||||
) (totalSecrets int, totalSize int64, oldestTime, latestTime time.Time, err error) {
|
) (int, int64, time.Time, time.Time, error) {
|
||||||
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, 0, time.Time{}, time.Time{}, err
|
return 0, 0, time.Time{}, time.Time{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stats vaultStats
|
||||||
|
|
||||||
for _, vaultEntry := range vaultEntries {
|
for _, vaultEntry := range vaultEntries {
|
||||||
if !vaultEntry.IsDir() {
|
if !vaultEntry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultPath := filepath.Join(vaultsDir, vaultEntry.Name())
|
stats.addVault(fs, vaultsDir, vaultEntry.Name())
|
||||||
secretsPath := filepath.Join(vaultPath, "secrets.d")
|
|
||||||
|
|
||||||
// Count secrets in this vault
|
|
||||||
secretEntries, err := afero.ReadDir(fs, secretsPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, secretEntry := range secretEntries {
|
|
||||||
if !secretEntry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
totalSecrets++
|
|
||||||
secretPath := filepath.Join(secretsPath, secretEntry.Name())
|
|
||||||
|
|
||||||
// Get size and timestamps from all versions
|
|
||||||
versionsPath := filepath.Join(secretPath, "versions")
|
|
||||||
versionEntries, err := afero.ReadDir(fs, versionsPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, versionEntry := range versionEntries {
|
|
||||||
if !versionEntry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
versionPath := filepath.Join(versionsPath, versionEntry.Name())
|
|
||||||
|
|
||||||
// Add size of encrypted data
|
|
||||||
dataPath := filepath.Join(versionPath, "data.age")
|
|
||||||
if stat, err := fs.Stat(dataPath); err == nil {
|
|
||||||
totalSize += stat.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add size of metadata
|
|
||||||
metaPath := filepath.Join(versionPath, "metadata.age")
|
|
||||||
if stat, err := fs.Stat(metaPath); err == nil {
|
|
||||||
totalSize += stat.Size()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track timestamps
|
|
||||||
if stat, err := fs.Stat(versionPath); err == nil {
|
|
||||||
modTime := stat.ModTime()
|
|
||||||
if oldestTime.IsZero() || modTime.Before(oldestTime) {
|
|
||||||
oldestTime = modTime
|
|
||||||
}
|
|
||||||
if latestTime.IsZero() || modTime.After(latestTime) {
|
|
||||||
latestTime = modTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSecrets, totalSize, oldestTime, latestTime, nil
|
return stats.totalSecrets, stats.totalSize,
|
||||||
|
stats.oldestTime, stats.latestTime, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
@@ -16,13 +18,17 @@ import (
|
|||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errPassphraseMismatch is returned when passphrase confirmation fails
|
||||||
|
var errPassphraseMismatch = errors.New("passphrases do not match")
|
||||||
|
|
||||||
// NewInitCmd creates the init command
|
// NewInitCmd creates the init command
|
||||||
func NewInitCmd() *cobra.Command {
|
func NewInitCmd() *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Initialize the secrets manager",
|
Short: "Initialize the secrets manager",
|
||||||
Long: `Create the necessary directory structure for storing secrets and generate encryption keys.`,
|
Long: `Create the necessary directory structure for storing ` +
|
||||||
RunE: RunInit,
|
`secrets and generate encryption keys.`,
|
||||||
|
RunE: RunInit,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +42,67 @@ func RunInit(cmd *cobra.Command, _ []string) error {
|
|||||||
return cli.Init(cmd)
|
return cli.Init(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// promptMnemonic reads the mnemonic from the environment or interactively.
|
||||||
|
// The returned cleanup function must be deferred by the caller.
|
||||||
|
func promptMnemonic() (string, func(), error) {
|
||||||
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
|
|
||||||
|
return envMnemonic, func() {}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
|
|
||||||
|
// Read mnemonic securely without echo
|
||||||
|
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
|
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
|
|
||||||
|
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupDefaultVault creates the default vault and derives its long-term
|
||||||
|
// identity from the mnemonic
|
||||||
|
func (cli *Instance) setupDefaultVault(
|
||||||
|
stateDir, mnemonicStr string,
|
||||||
|
) (*vault.Vault, *age.X25519Identity, error) {
|
||||||
|
// Create the default vault - it will handle key derivation internally
|
||||||
|
secret.Debug("Creating default vault")
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the vault metadata to retrieve the derivation index
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
||||||
|
|
||||||
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to load vault metadata", "error", err)
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive the long-term key using the same index that CreateVault used
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to derive long-term key", "error", err)
|
||||||
|
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return vlt, ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
// Init initializes the secret manager
|
// Init initializes the secret manager
|
||||||
func (cli *Instance) Init(cmd *cobra.Command) error {
|
func (cli *Instance) Init(cmd *cobra.Command) error {
|
||||||
secret.Debug("Starting secret manager initialization")
|
secret.Debug("Starting secret manager initialization")
|
||||||
@@ -44,7 +111,8 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
stateDir := cli.GetStateDir()
|
stateDir := cli.GetStateDir()
|
||||||
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
secret.DebugWith("Creating state directory", slog.String("path", stateDir))
|
||||||
|
|
||||||
if err := cli.fs.MkdirAll(stateDir, secret.DirPerms); err != nil {
|
err := cli.fs.MkdirAll(stateDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
secret.Debug("Failed to create state directory", "error", err)
|
secret.Debug("Failed to create state directory", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create state directory: %w", err)
|
return fmt.Errorf("failed to create state directory: %w", err)
|
||||||
@@ -55,100 +123,55 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for mnemonic
|
// Prompt for mnemonic
|
||||||
var mnemonicStr string
|
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
|
||||||
|
if err != nil {
|
||||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
return err
|
||||||
secret.Debug("Using mnemonic from environment variable")
|
|
||||||
mnemonicStr = envMnemonic
|
|
||||||
} else {
|
|
||||||
secret.Debug("Prompting user for mnemonic phrase")
|
|
||||||
// Read mnemonic securely without echo
|
|
||||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to read mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
defer mnemonicBuffer.Destroy()
|
|
||||||
|
|
||||||
mnemonicStr = mnemonicBuffer.String()
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
|
||||||
}
|
}
|
||||||
|
defer cleanupMnemonic()
|
||||||
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
secret.Debug("Empty mnemonic provided")
|
secret.Debug("Empty mnemonic provided")
|
||||||
|
|
||||||
return fmt.Errorf("mnemonic cannot be empty")
|
return errMnemonicEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the mnemonic using BIP39
|
// Validate the mnemonic using BIP39
|
||||||
secret.DebugWith("Validating BIP39 mnemonic", slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
secret.DebugWith("Validating BIP39 mnemonic",
|
||||||
|
slog.Int("word_count", len(strings.Fields(mnemonicStr))))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||||
secret.Debug("Invalid BIP39 mnemonic provided")
|
secret.Debug("Invalid BIP39 mnemonic provided")
|
||||||
|
|
||||||
return fmt.Errorf("invalid BIP39 mnemonic phrase\nRun 'secret generate mnemonic' to create a valid mnemonic")
|
return fmt.Errorf(
|
||||||
|
"%w\nRun 'secret generate mnemonic' to create a valid mnemonic",
|
||||||
|
errInvalidMnemonicPhrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mnemonic in environment for CreateVault to use
|
// Set mnemonic in environment for CreateVault to use
|
||||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
defer restoreMnemonicEnv()
|
||||||
defer func() {
|
|
||||||
if originalMnemonic != "" {
|
|
||||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
|
||||||
} else {
|
|
||||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create the default vault - it will handle key derivation internally
|
// Create the default vault and derive its long-term key
|
||||||
secret.Debug("Creating default vault")
|
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create default vault", "error", err)
|
return err
|
||||||
|
|
||||||
return fmt.Errorf("failed to create default vault: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to load vault metadata", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the long-term key using the same index that CreateVault used
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to derive long-term key", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
// Prompt for passphrase for unlocker
|
// Prompt for passphrase for unlocker
|
||||||
var passphraseBuffer *memguard.LockedBuffer
|
passphraseBuffer, err := resolvePassphrase()
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
if err != nil {
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
return err
|
||||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
|
||||||
} else {
|
|
||||||
secret.Debug("Prompting user for unlock passphrase")
|
|
||||||
// Use secure passphrase input with confirmation
|
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read unlock passphrase", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
@@ -194,7 +217,7 @@ func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
|||||||
passphraseBuffer1.Destroy()
|
passphraseBuffer1.Destroy()
|
||||||
passphraseBuffer2.Destroy()
|
passphraseBuffer2.Destroy()
|
||||||
|
|
||||||
return nil, fmt.Errorf("passphrases do not match")
|
return nil, errPassphraseMismatch
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the second buffer, we'll return the first
|
// Clean up the second buffer, we'll return the first
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,17 +10,21 @@ import (
|
|||||||
// Entry is the entry point for the secret CLI application
|
// Entry is the entry point for the secret CLI application
|
||||||
func Entry() {
|
func Entry() {
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
if err := cmd.Execute(); err != nil {
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRootCmd() *cobra.Command {
|
func newRootCmd() *cobra.Command {
|
||||||
secret.Debug("newRootCmd starting")
|
secret.Debug("newRootCmd starting")
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "secret",
|
Use: "secret",
|
||||||
Short: "A simple secrets manager",
|
Short: "A simple secrets manager",
|
||||||
Long: `A simple secrets manager to store and retrieve sensitive information securely.`,
|
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
||||||
|
`information securely.`,
|
||||||
// Ensure usage is shown after errors
|
// Ensure usage is shown after errors
|
||||||
SilenceUsage: false,
|
SilenceUsage: false,
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
@@ -20,12 +22,36 @@ const (
|
|||||||
vaultSecretSeparator = ":"
|
vaultSecretSeparator = ":"
|
||||||
// vaultSecretParts is the number of parts when splitting vault:secret
|
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||||
vaultSecretParts = 2
|
vaultSecretParts = 2
|
||||||
|
|
||||||
|
// initialBufferSize is the starting size for secret read buffers (4KB)
|
||||||
|
initialBufferSize = 4 * 1024
|
||||||
|
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
||||||
|
maxSecretSize = 100 * 1024 * 1024
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for secret operations
|
||||||
|
var (
|
||||||
|
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||||
|
errSecretFileTooLarge = errors.New(
|
||||||
|
"secret file too large: exceeds 100MB limit")
|
||||||
|
errSecretNotFound = errors.New("not found")
|
||||||
|
errSecretExistsNoForce = errors.New(
|
||||||
|
"already exists (use --force to overwrite)")
|
||||||
|
errVaultDoesNotExist = errors.New("does not exist")
|
||||||
|
errCrossVaultSourceUnqualified = errors.New(
|
||||||
|
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||||
|
)
|
||||||
|
|
||||||
|
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
||||||
|
type bufferInfo struct {
|
||||||
|
buffer *memguard.LockedBuffer
|
||||||
|
used int
|
||||||
|
}
|
||||||
|
|
||||||
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
||||||
// Returns (vaultName, secretName, isQualified)
|
// Returns (vaultName, secretName, isQualified)
|
||||||
// If no vault is specified, returns empty vaultName and isQualified=false
|
// If no vault is specified, returns empty vaultName and isQualified=false
|
||||||
func ParseVaultSecretRef(ref string) (vaultName, secretName string, isQualified bool) {
|
func ParseVaultSecretRef(ref string) (string, string, bool) {
|
||||||
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
parts := strings.SplitN(ref, vaultSecretSeparator, vaultSecretParts)
|
||||||
if len(parts) == vaultSecretParts {
|
if len(parts) == vaultSecretParts {
|
||||||
return parts[0], parts[1], true
|
return parts[0], parts[1], true
|
||||||
@@ -42,6 +68,7 @@ func newAddCmd() *cobra.Command {
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
secret.Debug("Add command RunE starting", "secret_name", args[0])
|
secret.Debug("Add command RunE starting", "secret_name", args[0])
|
||||||
|
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
secret.Debug("Got force flag", "force", force)
|
secret.Debug("Got force flag", "force", force)
|
||||||
|
|
||||||
@@ -49,7 +76,9 @@ func newAddCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd = cmd // Set the command for stdin access
|
cli.cmd = cmd // Set the command for stdin access
|
||||||
|
|
||||||
secret.Debug("Created CLI instance, calling AddSecret")
|
secret.Debug("Created CLI instance, calling AddSecret")
|
||||||
|
|
||||||
return cli.AddSecret(args[0], force)
|
return cli.AddSecret(args[0], force)
|
||||||
@@ -66,6 +95,7 @@ func newGetCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "get <secret-name>",
|
Use: "get <secret-name>",
|
||||||
Short: "Retrieve a secret from the vault",
|
Short: "Retrieve a secret from the vault",
|
||||||
@@ -73,6 +103,7 @@ func newGetCmd() *cobra.Command {
|
|||||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
version, _ := cmd.Flags().GetString("version")
|
version, _ := cmd.Flags().GetString("version")
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
@@ -92,8 +123,9 @@ func newListCmd() *cobra.Command {
|
|||||||
Use: "list [filter]",
|
Use: "list [filter]",
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Short: "List all secrets in the current vault",
|
Short: "List all secrets in the current vault",
|
||||||
Long: `List all secrets in the current vault. Optionally filter by substring match in secret name.`,
|
Long: `List all secrets in the current vault. Optionally filter ` +
|
||||||
Args: cobra.MaximumNArgs(1),
|
`by substring match in secret name.`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
jsonOutput, _ := cmd.Flags().GetBool("json")
|
jsonOutput, _ := cmd.Flags().GetBool("json")
|
||||||
quietOutput, _ := cmd.Flags().GetBool("quiet")
|
quietOutput, _ := cmd.Flags().GetBool("quiet")
|
||||||
@@ -122,8 +154,9 @@ func newImportCmd() *cobra.Command {
|
|||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "import <secret-name>",
|
Use: "import <secret-name>",
|
||||||
Short: "Import a secret from a file",
|
Short: "Import a secret from a file",
|
||||||
Long: `Import a secret from a file and store it in the current vault under the given name.`,
|
Long: `Import a secret from a file and store it in the current ` +
|
||||||
Args: cobra.ExactArgs(1),
|
`vault under the given name.`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
sourceFile, _ := cmd.Flags().GetString("source")
|
sourceFile, _ := cmd.Flags().GetString("source")
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
@@ -149,12 +182,13 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <secret-name>",
|
Use: "remove <secret-name>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a secret from the vault",
|
Short: "Remove a secret from the vault",
|
||||||
Long: `Remove a secret and all its versions from the current vault. This action is permanent and ` +
|
Long: `Remove a secret and all its versions from the current ` +
|
||||||
`cannot be undone.`,
|
`vault. This action is permanent and cannot be undone.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -175,6 +209,7 @@ func newMoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "move <source> <destination>",
|
Use: "move <source> <destination>",
|
||||||
Aliases: []string{"mv", "rename"},
|
Aliases: []string{"mv", "rename"},
|
||||||
@@ -190,13 +225,16 @@ For cross-vault moves:
|
|||||||
|
|
||||||
Cross-vault moves copy ALL versions of the secret, preserving history.
|
Cross-vault moves copy ALL versions of the secret, preserving history.
|
||||||
The source secret is deleted after successful copy.`,
|
The source secret is deleted after successful copy.`,
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: source and destination
|
Args: cobra.ExactArgs(2), //nolint:mnd // source and destination args
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
ValidArgsFunction: func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
// Complete vault:secret format
|
// Complete vault:secret format
|
||||||
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
return getVaultSecretCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
@@ -206,16 +244,20 @@ The source secret is deleted after successful copy.`,
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolP("force", "f", false, "Overwrite if destination secret already exists")
|
cmd.Flags().BoolP("force", "f", false,
|
||||||
|
"Overwrite if destination secret already exists")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateBufferSize updates the buffer size based on usage pattern
|
// updateBufferSize updates the buffer size based on usage pattern
|
||||||
func updateBufferSize(currentSize int, sameSize *int) int {
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||||
|
const (
|
||||||
|
doubleAfterBuffers = 2
|
||||||
|
growthFactor = 2
|
||||||
|
)
|
||||||
|
|
||||||
*sameSize++
|
*sameSize++
|
||||||
const doubleAfterBuffers = 2
|
|
||||||
const growthFactor = 2
|
|
||||||
if *sameSize >= doubleAfterBuffers {
|
if *sameSize >= doubleAfterBuffers {
|
||||||
*sameSize = 0
|
*sameSize = 0
|
||||||
|
|
||||||
@@ -225,40 +267,21 @@ func updateBufferSize(currentSize int, sameSize *int) int {
|
|||||||
return currentSize
|
return currentSize
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSecret adds a secret to the current vault
|
// destroyBuffers destroys every buffer in the list
|
||||||
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
func destroyBuffers(buffers []bufferInfo) {
|
||||||
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
for _, b := range buffers {
|
||||||
|
b.buffer.Destroy()
|
||||||
// Get current vault
|
|
||||||
secret.Debug("Getting current vault")
|
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
|
||||||
|
|
||||||
// Read secret value directly into protected buffers
|
|
||||||
secret.Debug("Reading secret value from stdin into protected buffers")
|
|
||||||
|
|
||||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
|
||||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
|
||||||
|
|
||||||
type bufferInfo struct {
|
|
||||||
buffer *memguard.LockedBuffer
|
|
||||||
used int
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSecretFromReader reads all data from reader into protected buffers,
|
||||||
|
// enforcing the maximum secret size. On failure the accumulated buffers
|
||||||
|
// are destroyed; on success the caller must destroy them.
|
||||||
|
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||||
var buffers []bufferInfo
|
var buffers []bufferInfo
|
||||||
defer func() {
|
|
||||||
for _, b := range buffers {
|
|
||||||
b.buffer.Destroy()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
reader := cli.cmd.InOrStdin()
|
|
||||||
totalSize := 0
|
totalSize := 0
|
||||||
currentBufferSize := initialSize
|
currentBufferSize := initialBufferSize
|
||||||
sameSize := 0
|
sameSize := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -273,8 +296,10 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||||
totalSize += n
|
totalSize += n
|
||||||
|
|
||||||
if totalSize > maxSize {
|
if totalSize > maxSecretSize {
|
||||||
return fmt.Errorf("secret too large: exceeds 100MB limit")
|
destroyBuffers(buffers)
|
||||||
|
|
||||||
|
return nil, 0, errSecretTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we filled the buffer, consider growing for next iteration
|
// If we filled the buffer, consider growing for next iteration
|
||||||
@@ -283,13 +308,59 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
break
|
break
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("failed to read secret value: %w", err)
|
destroyBuffers(buffers)
|
||||||
|
|
||||||
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return buffers, totalSize, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// combineBuffers copies the used portions of buffers into a single
|
||||||
|
// protected buffer of totalSize bytes
|
||||||
|
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
||||||
|
valueBuffer := memguard.NewBuffer(totalSize)
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for _, b := range buffers {
|
||||||
|
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||||
|
offset += b.used
|
||||||
|
}
|
||||||
|
|
||||||
|
return valueBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSecret adds a secret to the current vault
|
||||||
|
func (cli *Instance) AddSecret(secretName string, force bool) error {
|
||||||
|
secret.Debug("CLI AddSecret starting", "secret_name", secretName, "force", force)
|
||||||
|
|
||||||
|
// Get current vault
|
||||||
|
secret.Debug("Getting current vault")
|
||||||
|
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||||
|
|
||||||
|
// Read secret value directly into protected buffers
|
||||||
|
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||||
|
|
||||||
|
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errSecretTooLarge) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to read secret value: %w", err)
|
||||||
|
}
|
||||||
|
defer destroyBuffers(buffers)
|
||||||
|
|
||||||
// Check for trailing newline in the last buffer
|
// Check for trailing newline in the last buffer
|
||||||
if len(buffers) > 0 && totalSize > 0 {
|
if len(buffers) > 0 && totalSize > 0 {
|
||||||
lastBuffer := &buffers[len(buffers)-1]
|
lastBuffer := &buffers[len(buffers)-1]
|
||||||
@@ -299,21 +370,19 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Read secret value from stdin", "value_length", totalSize, "buffers", len(buffers))
|
secret.Debug("Read secret value from stdin",
|
||||||
|
"value_length", totalSize, "buffers", len(buffers))
|
||||||
|
|
||||||
// Combine all buffers into a single protected buffer
|
// Combine all buffers into a single protected buffer
|
||||||
valueBuffer := memguard.NewBuffer(totalSize)
|
valueBuffer := combineBuffers(buffers, totalSize)
|
||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
offset := 0
|
|
||||||
for _, b := range buffers {
|
|
||||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
|
||||||
offset += b.used
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the secret to the vault
|
// Add the secret to the vault
|
||||||
secret.Debug("Calling vault.AddSecret", "secret_name", secretName, "value_length", valueBuffer.Size(), "force", force)
|
secret.Debug("Calling vault.AddSecret", "secret_name", secretName,
|
||||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
"value_length", valueBuffer.Size(), "force", force)
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||||
|
if err != nil {
|
||||||
secret.Debug("vault.AddSecret failed", "error", err)
|
secret.Debug("vault.AddSecret failed", "error", err)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
@@ -330,8 +399,11 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
// GetSecretWithVersion retrieves and prints a specific version of a secret
|
||||||
func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string, version string) error {
|
func (cli *Instance) GetSecretWithVersion(
|
||||||
secret.Debug("GetSecretWithVersion called", "secretName", secretName, "version", version)
|
cmd *cobra.Command, secretName string, version string,
|
||||||
|
) error {
|
||||||
|
secret.Debug("GetSecretWithVersion called",
|
||||||
|
"secretName", secretName, "version", version)
|
||||||
|
|
||||||
// Store the command for output
|
// Store the command for output
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
@@ -351,6 +423,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
|||||||
} else {
|
} else {
|
||||||
value, err = vlt.GetSecretVersion(secretName, version)
|
value, err = vlt.GetSecretVersion(secretName, version)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get secret", "error", err)
|
secret.Debug("Failed to get secret", "error", err)
|
||||||
|
|
||||||
@@ -361,6 +434,7 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
|||||||
|
|
||||||
// Print the secret value to stdout
|
// Print the secret value to stdout
|
||||||
_, _ = cli.Print(string(value))
|
_, _ = cli.Print(string(value))
|
||||||
|
|
||||||
secret.Debug("Printed value to stdout")
|
secret.Debug("Printed value to stdout")
|
||||||
|
|
||||||
// Debug: Log what we're actually printing
|
// Debug: Log what we're actually printing
|
||||||
@@ -375,7 +449,9 @@ func (cli *Instance) GetSecretWithVersion(cmd *cobra.Command, secretName string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListSecrets lists all secrets in the current vault
|
// ListSecrets lists all secrets in the current vault
|
||||||
func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string) error {
|
func (cli *Instance) ListSecrets(
|
||||||
|
cmd *cobra.Command, jsonOutput bool, quietOutput bool, filter string,
|
||||||
|
) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -390,6 +466,7 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
|||||||
|
|
||||||
// Filter secrets if filter is provided
|
// Filter secrets if filter is provided
|
||||||
var filteredSecrets []string
|
var filteredSecrets []string
|
||||||
|
|
||||||
if filter != "" {
|
if filter != "" {
|
||||||
for _, secretName := range secrets {
|
for _, secretName := range secrets {
|
||||||
if strings.Contains(secretName, filter) {
|
if strings.Contains(secretName, filter) {
|
||||||
@@ -400,100 +477,132 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
|
|||||||
filteredSecrets = secrets
|
filteredSecrets = secrets
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput { //nolint:nestif // Separate JSON and table output formatting logic
|
switch {
|
||||||
// For JSON output, get metadata for each secret
|
case jsonOutput:
|
||||||
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets))
|
return printSecretsJSON(cmd, vlt, filteredSecrets, filter)
|
||||||
|
case quietOutput:
|
||||||
for _, secretName := range filteredSecrets {
|
|
||||||
secretInfo := map[string]interface{}{
|
|
||||||
"name": secretName,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get metadata using GetSecretObject
|
|
||||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
|
||||||
metadata := secretObj.GetMetadata()
|
|
||||||
secretInfo["created_at"] = metadata.CreatedAt
|
|
||||||
secretInfo["updated_at"] = metadata.UpdatedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
output := map[string]interface{}{
|
|
||||||
"secrets": secretsWithMetadata,
|
|
||||||
}
|
|
||||||
if filter != "" {
|
|
||||||
output["filter"] = filter
|
|
||||||
}
|
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
|
||||||
} else if quietOutput {
|
|
||||||
// Quiet output - just secret names
|
// Quiet output - just secret names
|
||||||
for _, secretName := range filteredSecrets {
|
for _, secretName := range filteredSecrets {
|
||||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Pretty table output
|
|
||||||
out := cmd.OutOrStdout()
|
|
||||||
if len(filteredSecrets) == 0 {
|
|
||||||
if filter != "" {
|
|
||||||
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter)
|
|
||||||
} else {
|
|
||||||
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
|
||||||
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
default:
|
||||||
|
return printSecretsTable(cmd, vlt, filteredSecrets, filter, len(secrets))
|
||||||
// Get current vault name for display
|
|
||||||
if filter != "" {
|
|
||||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter)
|
|
||||||
} else {
|
|
||||||
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the maximum name length for proper column alignment
|
|
||||||
maxNameLen := len("NAME") // Start with header length
|
|
||||||
for _, secretName := range filteredSecrets {
|
|
||||||
if len(secretName) > maxNameLen {
|
|
||||||
maxNameLen = len(secretName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add some padding
|
|
||||||
maxNameLen += 2
|
|
||||||
|
|
||||||
// Print headers with dynamic width
|
|
||||||
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
|
|
||||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
|
|
||||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------")
|
|
||||||
|
|
||||||
for _, secretName := range filteredSecrets {
|
|
||||||
lastUpdated := "unknown"
|
|
||||||
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
|
|
||||||
metadata := secretObj.GetMetadata()
|
|
||||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
|
||||||
if filter != "" {
|
|
||||||
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets))
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintln(out)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSecretsJSON prints the filtered secrets with metadata as JSON
|
||||||
|
func printSecretsJSON(
|
||||||
|
cmd *cobra.Command, vlt *vault.Vault, filteredSecrets []string, filter string,
|
||||||
|
) error {
|
||||||
|
// For JSON output, get metadata for each secret
|
||||||
|
secretsWithMetadata := make([]map[string]any, 0, len(filteredSecrets))
|
||||||
|
|
||||||
|
for _, secretName := range filteredSecrets {
|
||||||
|
secretInfo := map[string]any{
|
||||||
|
"name": secretName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get metadata using GetSecretObject
|
||||||
|
secretObj, err := vlt.GetSecretObject(secretName)
|
||||||
|
if err == nil {
|
||||||
|
metadata := secretObj.GetMetadata()
|
||||||
|
secretInfo["created_at"] = metadata.CreatedAt
|
||||||
|
secretInfo["updated_at"] = metadata.UpdatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
secretsWithMetadata = append(secretsWithMetadata, secretInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
output := map[string]any{
|
||||||
|
"secrets": secretsWithMetadata,
|
||||||
|
}
|
||||||
|
if filter != "" {
|
||||||
|
output["filter"] = filter
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.MarshalIndent(output, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSecretsTable prints the filtered secrets as a formatted table
|
||||||
|
func printSecretsTable(
|
||||||
|
cmd *cobra.Command, vlt *vault.Vault,
|
||||||
|
filteredSecrets []string, filter string, totalCount int,
|
||||||
|
) error {
|
||||||
|
// Pretty table output
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
|
||||||
|
if len(filteredSecrets) == 0 {
|
||||||
|
if filter != "" {
|
||||||
|
_, _ = fmt.Fprintf(out,
|
||||||
|
"No secrets found in vault '%s' matching filter '%s'.\n",
|
||||||
|
vlt.GetName(), filter)
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintln(out, "No secrets found in current vault.")
|
||||||
|
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current vault name for display
|
||||||
|
if filter != "" {
|
||||||
|
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n",
|
||||||
|
vlt.GetName(), filter)
|
||||||
|
} else {
|
||||||
|
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the maximum name length for proper column alignment
|
||||||
|
maxNameLen := len("NAME") // Start with header length
|
||||||
|
for _, secretName := range filteredSecrets {
|
||||||
|
if len(secretName) > maxNameLen {
|
||||||
|
maxNameLen = len(secretName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add some padding
|
||||||
|
maxNameLen += 2
|
||||||
|
|
||||||
|
// Print headers with dynamic width
|
||||||
|
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
|
||||||
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
|
||||||
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n",
|
||||||
|
strings.Repeat("-", len("NAME")), "------------")
|
||||||
|
|
||||||
|
for _, secretName := range filteredSecrets {
|
||||||
|
lastUpdated := "unknown"
|
||||||
|
|
||||||
|
secretObj, err := vlt.GetSecretObject(secretName)
|
||||||
|
if err == nil {
|
||||||
|
metadata := secretObj.GetMetadata()
|
||||||
|
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
|
||||||
|
if filter != "" {
|
||||||
|
_, _ = fmt.Fprintf(out, " (filtered from %d)", totalCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintln(out)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportSecret imports a secret from a file
|
// ImportSecret imports a secret from a file
|
||||||
func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile string, force bool) error {
|
func (cli *Instance) ImportSecret(
|
||||||
|
cmd *cobra.Command, secretName, sourceFile string, force bool,
|
||||||
|
) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -506,75 +615,34 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
|
|||||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := file.Close(); err != nil {
|
closeErr := file.Close()
|
||||||
secret.Warn("Failed to close file", "error", err)
|
if closeErr != nil {
|
||||||
|
secret.Warn("Failed to close file", "error", closeErr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
const initialSize = 4 * 1024 // 4KB initial buffer
|
buffers, totalSize, err := readSecretFromReader(file)
|
||||||
const maxSize = 100 * 1024 * 1024 // 100MB max
|
if err != nil {
|
||||||
|
if errors.Is(err, errSecretTooLarge) {
|
||||||
|
return errSecretFileTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
type bufferInfo struct {
|
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||||
buffer *memguard.LockedBuffer
|
|
||||||
used int
|
|
||||||
}
|
|
||||||
|
|
||||||
var buffers []bufferInfo
|
|
||||||
defer func() {
|
|
||||||
for _, b := range buffers {
|
|
||||||
b.buffer.Destroy()
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
totalSize := 0
|
|
||||||
currentBufferSize := initialSize
|
|
||||||
sameSize := 0
|
|
||||||
|
|
||||||
for {
|
|
||||||
// Create a new buffer
|
|
||||||
buffer := memguard.NewBuffer(currentBufferSize)
|
|
||||||
n, err := io.ReadFull(file, buffer.Bytes())
|
|
||||||
|
|
||||||
if n == 0 {
|
|
||||||
// No data read, destroy the unused buffer
|
|
||||||
buffer.Destroy()
|
|
||||||
} else {
|
|
||||||
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
|
||||||
totalSize += n
|
|
||||||
|
|
||||||
if totalSize > maxSize {
|
|
||||||
return fmt.Errorf("secret file too large: exceeds 100MB limit")
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we filled the buffer, consider growing for next iteration
|
|
||||||
if n == currentBufferSize {
|
|
||||||
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
|
||||||
break
|
|
||||||
} else if err != nil {
|
|
||||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
defer destroyBuffers(buffers)
|
||||||
|
|
||||||
// Combine all buffers into a single protected buffer
|
// Combine all buffers into a single protected buffer
|
||||||
valueBuffer := memguard.NewBuffer(totalSize)
|
valueBuffer := combineBuffers(buffers, totalSize)
|
||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
offset := 0
|
|
||||||
for _, b := range buffers {
|
|
||||||
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
|
||||||
offset += b.used
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the secret in the vault
|
// Store the secret in the vault
|
||||||
if err := vlt.AddSecret(secretName, valueBuffer, force); err != nil {
|
err = vlt.AddSecret(secretName, valueBuffer, force)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Successfully imported secret '%s' from file '%s'\n", secretName, sourceFile)
|
cmd.Printf("Successfully imported secret '%s' from file '%s'\n",
|
||||||
|
secretName, sourceFile)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -600,29 +668,36 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' not found", secretName)
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count versions for information
|
// Count versions for information
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
versionCount := 0
|
versionCount := 0
|
||||||
if entries, err := afero.ReadDir(cli.fs, versionsDir); err == nil {
|
|
||||||
|
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
||||||
|
if err == nil {
|
||||||
versionCount = len(entries)
|
versionCount = len(entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the secret directory
|
// Remove the secret directory
|
||||||
if err := cli.fs.RemoveAll(secretDir); err != nil {
|
err = cli.fs.RemoveAll(secretDir)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove secret: %w", err)
|
return fmt.Errorf("failed to remove secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", secretName, versionCount)
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
|
||||||
|
secretName, versionCount)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveSecret moves or renames a secret (within or across vaults)
|
// MoveSecret moves or renames a secret (within or across vaults)
|
||||||
func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force bool) error {
|
func (cli *Instance) MoveSecret(
|
||||||
|
cmd *cobra.Command, source, dest string, force bool,
|
||||||
|
) error {
|
||||||
// Parse source and destination
|
// Parse source and destination
|
||||||
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
srcVaultName, srcSecretName, srcQualified := ParseVaultSecretRef(source)
|
||||||
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
|
destVaultName, destSecretName, destQualified := ParseVaultSecretRef(dest)
|
||||||
@@ -634,25 +709,20 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
|||||||
|
|
||||||
// Cross-vault move requires source to be qualified
|
// Cross-vault move requires source to be qualified
|
||||||
if !srcQualified {
|
if !srcQualified {
|
||||||
return fmt.Errorf("source must specify vault (e.g., vault:secret) for cross-vault move")
|
return errCrossVaultSourceUnqualified
|
||||||
}
|
}
|
||||||
|
|
||||||
// If destination is not qualified (no colon), check if it's a vault name
|
// If destination is not qualified (no colon), check if it's a vault name
|
||||||
// Format: "work:secret default" means move to vault "default"
|
// Format: "work:secret default" means move to vault "default"
|
||||||
// Format: "work:secret default:newname" means move to vault "default" with new name
|
// Format: "work:secret default:newname" means move to vault "default"
|
||||||
|
// with a new name
|
||||||
if !destQualified {
|
if !destQualified {
|
||||||
// Check if dest is actually a vault name
|
// Check if dest is actually a vault name
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
if err == nil {
|
if err == nil && slices.Contains(vaults, dest) {
|
||||||
for _, v := range vaults {
|
// dest is a vault name, use source secret name
|
||||||
if v == dest {
|
destVaultName = dest
|
||||||
// dest is a vault name, use source secret name
|
destSecretName = srcSecretName
|
||||||
destVaultName = dest
|
|
||||||
destSecretName = srcSecretName
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If destVaultName is still empty, dest is a secret name in source vault
|
// If destVaultName is still empty, dest is a secret name in source vault
|
||||||
@@ -670,7 +740,8 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
|||||||
// Same vault? Use simple rename if possible (optimization)
|
// Same vault? Use simple rename if possible (optimization)
|
||||||
if srcVaultName == destVaultName {
|
if srcVaultName == destVaultName {
|
||||||
// Select the vault and do a simple move
|
// Select the vault and do a simple move
|
||||||
if err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName); err != nil {
|
err := vault.SelectVault(cli.fs, cli.stateDir, srcVaultName)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
return fmt.Errorf("failed to select vault '%s': %w", srcVaultName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -678,11 +749,14 @@ func (cli *Instance) MoveSecret(cmd *cobra.Command, source, dest string, force b
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cross-vault move
|
// Cross-vault move
|
||||||
return cli.moveSecretCrossVault(cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
return cli.moveSecretCrossVault(
|
||||||
|
cmd, srcVaultName, srcSecretName, destVaultName, destSecretName, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
// moveSecretWithinVault handles rename within the current vault
|
// moveSecretWithinVault handles rename within the current vault
|
||||||
func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest string, force bool) error {
|
func (cli *Instance) moveSecretWithinVault(
|
||||||
|
cmd *cobra.Command, source, dest string, force bool,
|
||||||
|
) error {
|
||||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -702,7 +776,7 @@ func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' not found", source)
|
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||||
@@ -715,15 +789,17 @@ func (cli *Instance) moveSecretWithinVault(cmd *cobra.Command, source, dest stri
|
|||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
if !force {
|
if !force {
|
||||||
return fmt.Errorf("secret '%s' already exists (use --force to overwrite)", dest)
|
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cli.fs.RemoveAll(destDir); err != nil {
|
err = cli.fs.RemoveAll(destDir)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove existing destination: %w", err)
|
return fmt.Errorf("failed to remove existing destination: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cli.fs.Rename(sourceDir, destDir); err != nil {
|
err = cli.fs.Rename(sourceDir, destDir)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to move secret: %w", err)
|
return fmt.Errorf("failed to move secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -741,8 +817,8 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
) error {
|
) error {
|
||||||
// Get source vault
|
// Get source vault
|
||||||
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
srcVault := vault.NewVault(cli.fs, cli.stateDir, srcVaultName)
|
||||||
srcVaultDir, err := srcVault.GetDirectory()
|
|
||||||
|
|
||||||
|
srcVaultDir, err := srcVault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get source vault directory: %w", err)
|
return fmt.Errorf("failed to get source vault directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -750,7 +826,7 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
// Verify source vault exists
|
// Verify source vault exists
|
||||||
exists, err := afero.DirExists(cli.fs, srcVaultDir)
|
exists, err := afero.DirExists(cli.fs, srcVaultDir)
|
||||||
if err != nil || !exists {
|
if err != nil || !exists {
|
||||||
return fmt.Errorf("source vault '%s' does not exist", srcVaultName)
|
return fmt.Errorf("source vault '%s' %w", srcVaultName, errVaultDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify source secret exists
|
// Verify source secret exists
|
||||||
@@ -759,13 +835,14 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
|
|
||||||
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
exists, err = afero.DirExists(cli.fs, srcSecretDir)
|
||||||
if err != nil || !exists {
|
if err != nil || !exists {
|
||||||
return fmt.Errorf("secret '%s' not found in vault '%s'", srcSecretName, srcVaultName)
|
return fmt.Errorf("secret '%s' %w in vault '%s'",
|
||||||
|
srcSecretName, errSecretNotFound, srcVaultName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get destination vault
|
// Get destination vault
|
||||||
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
destVault := vault.NewVault(cli.fs, cli.stateDir, destVaultName)
|
||||||
destVaultDir, err := destVault.GetDirectory()
|
|
||||||
|
|
||||||
|
destVaultDir, err := destVault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
}
|
}
|
||||||
@@ -773,7 +850,8 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
// Verify destination vault exists
|
// Verify destination vault exists
|
||||||
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
exists, err = afero.DirExists(cli.fs, destVaultDir)
|
||||||
if err != nil || !exists {
|
if err != nil || !exists {
|
||||||
return fmt.Errorf("destination vault '%s' does not exist", destVaultName)
|
return fmt.Errorf("destination vault '%s' %w",
|
||||||
|
destVaultName, errVaultDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||||
@@ -787,12 +865,15 @@ func (cli *Instance) moveSecretCrossVault(
|
|||||||
versionCount := len(versions)
|
versionCount := len(versions)
|
||||||
|
|
||||||
// Copy all versions
|
// Copy all versions
|
||||||
if err := destVault.CopySecretAllVersions(srcVault, srcSecretName, destSecretName, force); err != nil {
|
err = destVault.CopySecretAllVersions(
|
||||||
|
srcVault, srcSecretName, destSecretName, force)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete source secret
|
// Delete source secret
|
||||||
if err := cli.fs.RemoveAll(srcSecretDir); err != nil {
|
err = cli.fs.RemoveAll(srcSecretDir)
|
||||||
|
if err != nil {
|
||||||
// Copy succeeded but delete failed - warn but don't fail
|
// Copy succeeded but delete failed - warn but don't fail
|
||||||
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
cmd.Printf("Warning: copied secret but failed to remove source: %v\n", err)
|
||||||
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
cmd.Printf("Moved secret '%s:%s' to '%s:%s' (%d version(s))\n",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -18,7 +19,144 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testVaultName is the vault name used by the size tests.
|
||||||
|
const testVaultName = "test-vault"
|
||||||
|
|
||||||
|
// newSizeTestVault creates an in-memory vault unlocked with the test
|
||||||
|
// mnemonic and returns the filesystem and vault.
|
||||||
|
//
|
||||||
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||||
|
func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// Set test mnemonic
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
|
// Create vault
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set current vault
|
||||||
|
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||||
|
vaultPath := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get vault and set up long-term key
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
return fs, vlt
|
||||||
|
}
|
||||||
|
|
||||||
|
// runAddSecretSizeCase adds a secret of the given size through stdin and
|
||||||
|
// verifies the outcome.
|
||||||
|
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
|
// Generate test data of specified size
|
||||||
|
testData := make([]byte, size)
|
||||||
|
_, err := rand.Read(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Add newline that will be stripped
|
||||||
|
testDataWithNewline := make([]byte, 0, len(testData)+1)
|
||||||
|
testDataWithNewline = append(testDataWithNewline, testData...)
|
||||||
|
testDataWithNewline = append(testDataWithNewline, '\n')
|
||||||
|
|
||||||
|
// Create command with fake stdin
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetIn(bytes.NewReader(testDataWithNewline))
|
||||||
|
|
||||||
|
// Create CLI instance
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.fs = fs
|
||||||
|
cli.stateDir = testStateDir
|
||||||
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
// Test adding the secret
|
||||||
|
secretName := fmt.Sprintf("test-secret-%d", size)
|
||||||
|
err = cli.AddSecret(secretName, false)
|
||||||
|
|
||||||
|
if wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the secret was stored correctly
|
||||||
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue,
|
||||||
|
"Retrieved secret should match original (without newline)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// runImportSecretSizeCase imports a secret file of the given size and
|
||||||
|
// verifies the outcome.
|
||||||
|
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
|
// Generate test data of specified size
|
||||||
|
testData := make([]byte, size)
|
||||||
|
_, err := rand.Read(testData)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write test data to file
|
||||||
|
testFile := fmt.Sprintf("/test/secret-%d.bin", size)
|
||||||
|
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create command
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
|
// Create CLI instance
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.fs = fs
|
||||||
|
cli.stateDir = testStateDir
|
||||||
|
|
||||||
|
// Test importing the secret
|
||||||
|
secretName := fmt.Sprintf("imported-secret-%d", size)
|
||||||
|
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
||||||
|
|
||||||
|
if wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), errMsg)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify the secret was stored correctly
|
||||||
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
||||||
|
}
|
||||||
|
|
||||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||||
func TestAddSecretVariousSizes(t *testing.T) {
|
func TestAddSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -71,76 +209,14 @@ func TestAddSecretVariousSizes(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Set up test environment
|
runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set test mnemonic
|
|
||||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vaultName := "test-vault"
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set current vault
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
||||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
|
||||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get vault and set up long-term key
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Generate test data of specified size
|
|
||||||
testData := make([]byte, tt.size)
|
|
||||||
_, err = rand.Read(testData)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Add newline that will be stripped
|
|
||||||
testDataWithNewline := append(testData, '\n')
|
|
||||||
|
|
||||||
// Create fake stdin
|
|
||||||
stdin := bytes.NewReader(testDataWithNewline)
|
|
||||||
|
|
||||||
// Create command with fake stdin
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetIn(stdin)
|
|
||||||
|
|
||||||
// Create CLI instance
|
|
||||||
cli, err := NewCLIInstance()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
|
||||||
}
|
|
||||||
cli.fs = fs
|
|
||||||
cli.stateDir = stateDir
|
|
||||||
cli.cmd = cmd
|
|
||||||
|
|
||||||
// Test adding the secret
|
|
||||||
secretName := fmt.Sprintf("test-secret-%d", tt.size)
|
|
||||||
err = cli.AddSecret(secretName, false)
|
|
||||||
|
|
||||||
if tt.shouldError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify the secret was stored correctly
|
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original (without newline)")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||||
func TestImportSecretVariousSizes(t *testing.T) {
|
func TestImportSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -193,73 +269,14 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Set up test environment
|
runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set test mnemonic
|
|
||||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vaultName := "test-vault"
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set current vault
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
||||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
|
||||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get vault and set up long-term key
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Generate test data of specified size
|
|
||||||
testData := make([]byte, tt.size)
|
|
||||||
_, err = rand.Read(testData)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Write test data to file
|
|
||||||
testFile := fmt.Sprintf("/test/secret-%d.bin", tt.size)
|
|
||||||
err = afero.WriteFile(fs, testFile, testData, 0o600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create command
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
|
|
||||||
// Create CLI instance
|
|
||||||
cli, err := NewCLIInstance()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
|
||||||
}
|
|
||||||
cli.fs = fs
|
|
||||||
cli.stateDir = stateDir
|
|
||||||
|
|
||||||
// Test importing the secret
|
|
||||||
secretName := fmt.Sprintf("imported-secret-%d", tt.size)
|
|
||||||
err = cli.ImportSecret(cmd, secretName, testFile, false)
|
|
||||||
|
|
||||||
if tt.shouldError {
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), tt.errorMsg)
|
|
||||||
} else {
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify the secret was stored correctly
|
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
||||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
// Test various sizes that should trigger buffer growth
|
// Test various sizes that should trigger buffer growth
|
||||||
sizes := []int{
|
sizes := []int{
|
||||||
@@ -283,31 +300,7 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
|
|
||||||
for _, size := range sizes {
|
for _, size := range sizes {
|
||||||
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
t.Run(fmt.Sprintf("size_%d", size), func(t *testing.T) {
|
||||||
// Set up test environment
|
fs, vlt := newSizeTestVault(t)
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set test mnemonic
|
|
||||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vaultName := "test-vault"
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set current vault
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
||||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
|
||||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get vault and set up long-term key
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Create test data of exactly the specified size
|
// Create test data of exactly the specified size
|
||||||
// Use a pattern that's easy to verify
|
// Use a pattern that's easy to verify
|
||||||
@@ -316,20 +309,18 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
testData[i] = byte(i % 256)
|
testData[i] = byte(i % 256)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create fake stdin without newline
|
// Create command with fake stdin (no newline)
|
||||||
stdin := bytes.NewReader(testData)
|
|
||||||
|
|
||||||
// Create command with fake stdin
|
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
cmd.SetIn(stdin)
|
cmd.SetIn(bytes.NewReader(testData))
|
||||||
|
|
||||||
// Create CLI instance
|
// Create CLI instance
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.fs = fs
|
cli.fs = fs
|
||||||
cli.stateDir = stateDir
|
cli.stateDir = testStateDir
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
// Test adding the secret
|
// Test adding the secret
|
||||||
@@ -340,58 +331,38 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original exactly")
|
assert.Equal(t, testData, retrievedValue,
|
||||||
|
"Retrieved secret should match original exactly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
|
||||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||||
// Set up test environment
|
fs, vlt := newSizeTestVault(t)
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set test mnemonic
|
|
||||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vaultName := "test-vault"
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, vaultName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Set current vault
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
||||||
vaultPath := filepath.Join(stateDir, "vaults.d", vaultName)
|
|
||||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultPath), 0o600)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Get vault and set up long-term key
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", 0)
|
|
||||||
require.NoError(t, err)
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Create a custom reader that simulates slow streaming input
|
// Create a custom reader that simulates slow streaming input
|
||||||
// This will help verify our buffer handling works correctly with partial reads
|
// This will help verify our buffer handling works correctly with partial reads
|
||||||
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
testData := []byte(strings.Repeat("Hello, World! ", 1000)) // ~14KB
|
||||||
slowReader := &slowReader{
|
streamingStdin := &slowReader{
|
||||||
data: testData,
|
data: testData,
|
||||||
chunkSize: 1000, // Read 1KB at a time
|
chunkSize: 1000, // Read 1KB at a time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create command with slow reader as stdin
|
// Create command with slow reader as stdin
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
cmd.SetIn(slowReader)
|
cmd.SetIn(streamingStdin)
|
||||||
|
|
||||||
// Create CLI instance
|
// Create CLI instance
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.fs = fs
|
cli.fs = fs
|
||||||
cli.stateDir = stateDir
|
cli.stateDir = testStateDir
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
// Test adding the secret
|
// Test adding the secret
|
||||||
@@ -411,27 +382,22 @@ type slowReader struct {
|
|||||||
chunkSize int
|
chunkSize int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *slowReader) Read(p []byte) (n int, err error) {
|
func (r *slowReader) Read(p []byte) (int, error) {
|
||||||
if r.offset >= len(r.data) {
|
if r.offset >= len(r.data) {
|
||||||
return 0, io.EOF
|
return 0, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read at most chunkSize bytes
|
// Read at most chunkSize bytes, bounded by the remaining data and
|
||||||
|
// the destination buffer
|
||||||
remaining := len(r.data) - r.offset
|
remaining := len(r.data) - r.offset
|
||||||
toRead := r.chunkSize
|
toRead := min(r.chunkSize, remaining, len(p))
|
||||||
if toRead > remaining {
|
|
||||||
toRead = remaining
|
|
||||||
}
|
|
||||||
if toRead > len(p) {
|
|
||||||
toRead = len(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
n = copy(p, r.data[r.offset:r.offset+toRead])
|
n := copy(p, r.data[r.offset:r.offset+toRead])
|
||||||
r.offset += n
|
r.offset += n
|
||||||
|
|
||||||
if r.offset >= len(r.data) {
|
if r.offset >= len(r.data) {
|
||||||
err = io.EOF
|
return n, io.EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
return n, err
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,57 +7,64 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret value to stdout, not stderr
|
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
|
||||||
|
// value to stdout, not stderr
|
||||||
func TestGetCommandOutputsToStdout(t *testing.T) {
|
func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||||
// Create a temporary directory for our vault
|
// Create a temporary directory for our vault
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Set environment variables for the test
|
// Set environment variables for the test
|
||||||
t.Setenv("SB_SECRET_STATE_DIR", tempDir)
|
t.Setenv(secret.EnvStateDir, tempDir)
|
||||||
|
|
||||||
// Find the secret binary path
|
// Find the secret binary path
|
||||||
wd, err := filepath.Abs("../..")
|
wd, err := filepath.Abs("../..")
|
||||||
require.NoError(t, err, "should get working directory")
|
require.NoError(t, err, "should get working directory")
|
||||||
secretPath := filepath.Join(wd, "secret")
|
|
||||||
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
secretPath := filepath.Join(wd, "secret")
|
||||||
testPassphrase := "test-passphrase"
|
testPassphrase := "test-passphrase"
|
||||||
|
|
||||||
// Initialize vault
|
// Initialize vault
|
||||||
cmd := exec.Command(secretPath, "init")
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(t.Context(), secretPath, "init")
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
secret.EnvStateDir + "=" + tempDir,
|
||||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
"SB_UNLOCK_PASSPHRASE=" + testPassphrase,
|
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||||
"PATH=" + "/usr/bin:/bin",
|
"PATH=" + "/usr/bin:/bin",
|
||||||
}
|
}
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "init should succeed: %s", string(output))
|
require.NoError(t, err, "init should succeed: %s", string(output))
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
cmd = exec.Command(secretPath, "add", "test/secret")
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd = exec.CommandContext(t.Context(), secretPath, "add", "test/secret")
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
secret.EnvStateDir + "=" + tempDir,
|
||||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
"PATH=" + "/usr/bin:/bin",
|
"PATH=" + "/usr/bin:/bin",
|
||||||
}
|
}
|
||||||
cmd.Stdin = strings.NewReader("test-secret-value")
|
cmd.Stdin = strings.NewReader("test-secret-value")
|
||||||
|
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
require.NoError(t, err, "add should succeed: %s", string(output))
|
require.NoError(t, err, "add should succeed: %s", string(output))
|
||||||
|
|
||||||
// Test that 'secret get' outputs to stdout, not stderr
|
// Test that 'secret get' outputs to stdout, not stderr
|
||||||
cmd = exec.Command(secretPath, "get", "test/secret")
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd = exec.CommandContext(t.Context(), secretPath, "get", "test/secret")
|
||||||
cmd.Env = []string{
|
cmd.Env = []string{
|
||||||
"SB_SECRET_STATE_DIR=" + tempDir,
|
secret.EnvStateDir + "=" + tempDir,
|
||||||
"SB_SECRET_MNEMONIC=" + testMnemonic,
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
"PATH=" + "/usr/bin:/bin",
|
"PATH=" + "/usr/bin:/bin",
|
||||||
}
|
}
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
@@ -65,7 +72,8 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
|||||||
require.NoError(t, err, "get should succeed")
|
require.NoError(t, err, "get should succeed")
|
||||||
|
|
||||||
// The secret value should be in stdout
|
// The secret value should be in stdout
|
||||||
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()), "secret value should be in stdout")
|
assert.Equal(t, "test-secret-value", strings.TrimSpace(stdout.String()),
|
||||||
|
"secret value should be in stdout")
|
||||||
|
|
||||||
// Nothing should be in stderr
|
// Nothing should be in stderr
|
||||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
// ExecuteCommandInProcess executes a CLI command in-process for testing
|
||||||
func ExecuteCommandInProcess(args []string, stdin string, env map[string]string) (string, error) {
|
func ExecuteCommandInProcess(
|
||||||
|
args []string, stdin string, env map[string]string,
|
||||||
|
) (string, error) {
|
||||||
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
secret.Debug("ExecuteCommandInProcess called", "args", args)
|
||||||
|
|
||||||
// Save current environment
|
// Save current environment
|
||||||
@@ -43,11 +45,13 @@ func ExecuteCommandInProcess(args []string, stdin string, env map[string]string)
|
|||||||
err := rootCmd.Execute()
|
err := rootCmd.Execute()
|
||||||
|
|
||||||
output := buf.String()
|
output := buf.String()
|
||||||
secret.Debug("Command execution completed", "error", err, "outputLength", len(output), "output", output)
|
secret.Debug("Command execution completed",
|
||||||
|
"error", err, "outputLength", len(output), "output", output)
|
||||||
|
|
||||||
// Add debug info for troubleshooting
|
// Add debug info for troubleshooting
|
||||||
if len(output) == 0 && err == nil {
|
if len(output) == 0 && err == nil {
|
||||||
secret.Debug("Warning: Command executed successfully but produced no output", "args", args)
|
secret.Debug("Warning: Command executed successfully but produced no output",
|
||||||
|
"args", args)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore environment
|
// Restore environment
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
package cli
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:paralleltest // executes the CLI in-process against shared state
|
||||||
func TestOutputCapture(t *testing.T) {
|
func TestOutputCapture(t *testing.T) {
|
||||||
// Test vault list command which we fixed
|
// Test vault list command which we fixed
|
||||||
output, err := ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
output, err := cli.ExecuteCommandInProcess([]string{"vault", "list"}, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
assert.Contains(t, output, "Available vaults", "should capture vault list output")
|
||||||
t.Logf("vault list output: %q", output)
|
t.Logf("vault list output: %q", output)
|
||||||
|
|
||||||
// Test help command
|
// Test help command
|
||||||
output, err = ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
output, err = cli.ExecuteCommandInProcess([]string{"--help"}, "", nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotEmpty(t, output, "help output should not be empty")
|
assert.NotEmpty(t, output, "help output should not be empty")
|
||||||
t.Logf("help output length: %d", len(output))
|
t.Logf("help output length: %d", len(output))
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +21,37 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Unlocker type names and platform identifiers shared across the CLI
|
||||||
|
const (
|
||||||
|
unlockerTypePassphrase = "passphrase"
|
||||||
|
unlockerTypeKeychain = "keychain"
|
||||||
|
unlockerTypePGP = "pgp"
|
||||||
|
unlockerTypeSecureEnclave = "secure-enclave"
|
||||||
|
|
||||||
|
platformDarwin = "darwin"
|
||||||
|
|
||||||
|
cmdUseList = "list"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for unlocker operations
|
||||||
|
var (
|
||||||
|
errNoGPGSecretKeys = errors.New("no GPG secret keys found")
|
||||||
|
errInvalidUnlockerType = errors.New("invalid unlocker type")
|
||||||
|
errKeyIDOnlyForPGP = errors.New(
|
||||||
|
"--keyid flag is only valid for PGP unlockers")
|
||||||
|
errKeychainMacOSOnly = errors.New(
|
||||||
|
"keychain unlockers are only supported on macOS")
|
||||||
|
errSecureEnclaveMacOSOnly = errors.New(
|
||||||
|
"secure enclave unlockers are only supported on macOS")
|
||||||
|
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller
|
||||||
|
// composes "GPG key <id> is already added as an unlocker".
|
||||||
|
errGPGKeyAlreadyUnlocker = errors.New(
|
||||||
|
"is already added as an unlocker")
|
||||||
|
errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||||
|
errLastUnlocker = errors.New("refusing to remove last unlocker")
|
||||||
|
errUnlockerExists = errors.New("unlocker already exists")
|
||||||
|
)
|
||||||
|
|
||||||
// UnlockerInfo represents unlocker information for display
|
// UnlockerInfo represents unlocker information for display
|
||||||
type UnlockerInfo struct {
|
type UnlockerInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -37,12 +71,14 @@ const (
|
|||||||
|
|
||||||
// getDefaultGPGKey returns the default GPG key ID if available
|
// getDefaultGPGKey returns the default GPG key ID if available
|
||||||
func getDefaultGPGKey() (string, error) {
|
func getDefaultGPGKey() (string, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
// First try to get the configured default key using gpgconf
|
// First try to get the configured default key using gpgconf
|
||||||
cmd := exec.Command("gpgconf", "--list-options", "gpg")
|
cmd := exec.CommandContext(ctx, "gpgconf", "--list-options", "gpg")
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lines := strings.Split(string(output), "\n")
|
for line := range strings.SplitSeq(string(output), "\n") {
|
||||||
for _, line := range lines {
|
|
||||||
fields := strings.Split(line, ":")
|
fields := strings.Split(line, ":")
|
||||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||||
// The default key is in field 10 (index 9)
|
// The default key is in field 10 (index 9)
|
||||||
@@ -52,15 +88,15 @@ func getDefaultGPGKey() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no default key is configured, get the first secret key
|
// If no default key is configured, get the first secret key
|
||||||
cmd = exec.Command("gpg", "--list-secret-keys", "--with-colons")
|
cmd = exec.CommandContext(ctx, "gpg", "--list-secret-keys", "--with-colons")
|
||||||
|
|
||||||
output, err = cmd.Output()
|
output, err = cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
return "", fmt.Errorf("failed to list GPG keys: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse output to find the first usable secret key
|
// Parse output to find the first usable secret key
|
||||||
lines := strings.Split(string(output), "\n")
|
for line := range strings.SplitSeq(string(output), "\n") {
|
||||||
for _, line := range lines {
|
|
||||||
// sec line indicates a secret key
|
// sec line indicates a secret key
|
||||||
if strings.HasPrefix(line, "sec:") {
|
if strings.HasPrefix(line, "sec:") {
|
||||||
fields := strings.Split(line, ":")
|
fields := strings.Split(line, ":")
|
||||||
@@ -71,7 +107,7 @@ func getDefaultGPGKey() (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("no GPG secret keys found")
|
return "", errNoGPGSecretKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerCmd() *cobra.Command {
|
func newUnlockerCmd() *cobra.Command {
|
||||||
@@ -91,7 +127,7 @@ func newUnlockerCmd() *cobra.Command {
|
|||||||
|
|
||||||
func newUnlockerListCmd() *cobra.Command {
|
func newUnlockerListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: cmdUseList,
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Short: "List unlockers in the current vault",
|
Short: "List unlockers in the current vault",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
@@ -101,6 +137,7 @@ func newUnlockerListCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
return cli.UnlockersList(jsonOutput)
|
return cli.UnlockersList(jsonOutput)
|
||||||
@@ -112,53 +149,80 @@ func newUnlockerListCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func newUnlockerAddCmd() *cobra.Command {
|
// unlockerAddHelp returns the supported unlocker types list and their
|
||||||
|
// descriptions for the current platform
|
||||||
|
func unlockerAddHelp() (string, string) {
|
||||||
// Build the supported types list based on platform
|
// Build the supported types list based on platform
|
||||||
supportedTypes := "passphrase, pgp"
|
supportedTypes := "passphrase, pgp"
|
||||||
typeDescriptions := `Available unlocker types:
|
typeDescriptions := "Available unlocker types:\n" +
|
||||||
|
"\n" +
|
||||||
|
" passphrase - Traditional password-based encryption\n" +
|
||||||
|
" Prompts for a passphrase that will be used to " +
|
||||||
|
"encrypt/decrypt the vault's master key.\n" +
|
||||||
|
" The passphrase is never stored in plaintext.\n" +
|
||||||
|
"\n" +
|
||||||
|
" pgp - GNU Privacy Guard (GPG) key-based encryption \n" +
|
||||||
|
" Uses your existing GPG key to encrypt/decrypt " +
|
||||||
|
"the vault's master key.\n" +
|
||||||
|
" Requires gpg to be installed and configured " +
|
||||||
|
"with at least one secret key.\n" +
|
||||||
|
" Use --keyid to specify a particular key, " +
|
||||||
|
"otherwise uses your default GPG key."
|
||||||
|
|
||||||
passphrase - Traditional password-based encryption
|
if runtime.GOOS == platformDarwin {
|
||||||
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
|
||||||
The passphrase is never stored in plaintext.
|
|
||||||
|
|
||||||
pgp - GNU Privacy Guard (GPG) key-based encryption
|
|
||||||
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
|
||||||
Requires gpg to be installed and configured with at least one secret key.
|
|
||||||
Use --keyid to specify a particular key, otherwise uses your default GPG key.`
|
|
||||||
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||||
typeDescriptions = `Available unlocker types:
|
typeDescriptions = "Available unlocker types:\n" +
|
||||||
|
"\n" +
|
||||||
passphrase - Traditional password-based encryption
|
" passphrase - Traditional password-based encryption\n" +
|
||||||
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key.
|
" Prompts for a passphrase that will be " +
|
||||||
The passphrase is never stored in plaintext.
|
"used to encrypt/decrypt the vault's master key.\n" +
|
||||||
|
" The passphrase is never stored in " +
|
||||||
keychain - macOS Keychain integration (macOS only)
|
"plaintext.\n" +
|
||||||
Stores the vault's master key in the macOS Keychain, protected by your login password.
|
"\n" +
|
||||||
Automatically unlocks when your Keychain is unlocked (e.g., after login).
|
" keychain - macOS Keychain integration (macOS only)\n" +
|
||||||
Provides seamless integration with macOS security features like Touch ID.
|
" Stores the vault's master key in the " +
|
||||||
|
"macOS Keychain, protected by your login password.\n" +
|
||||||
pgp - GNU Privacy Guard (GPG) key-based encryption
|
" Automatically unlocks when your Keychain " +
|
||||||
Uses your existing GPG key to encrypt/decrypt the vault's master key.
|
"is unlocked (e.g., after login).\n" +
|
||||||
Requires gpg to be installed and configured with at least one secret key.
|
" Provides seamless integration with macOS " +
|
||||||
Use --keyid to specify a particular key, otherwise uses your default GPG key.
|
"security features like Touch ID.\n" +
|
||||||
|
"\n" +
|
||||||
secure-enclave - Apple Secure Enclave hardware protection (macOS only)
|
" pgp - GNU Privacy Guard (GPG) key-based " +
|
||||||
Stores the vault's master key encrypted by a non-exportable P-256 key
|
"encryption\n" +
|
||||||
held in the Secure Enclave. The key never leaves the hardware.
|
" Uses your existing GPG key to " +
|
||||||
Uses ECIES encryption; decryption is performed inside the SE.`
|
"encrypt/decrypt the vault's master key.\n" +
|
||||||
|
" Requires gpg to be installed and " +
|
||||||
|
"configured with at least one secret key.\n" +
|
||||||
|
" Use --keyid to specify a particular key, " +
|
||||||
|
"otherwise uses your default GPG key.\n" +
|
||||||
|
"\n" +
|
||||||
|
" secure-enclave - Apple Secure Enclave hardware protection " +
|
||||||
|
"(macOS only)\n" +
|
||||||
|
" Stores the vault's master key encrypted " +
|
||||||
|
"by a non-exportable P-256 key\n" +
|
||||||
|
" held in the Secure Enclave. The key " +
|
||||||
|
"never leaves the hardware.\n" +
|
||||||
|
" Uses ECIES encryption; decryption is " +
|
||||||
|
"performed inside the SE."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return supportedTypes, typeDescriptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnlockerAddCmd() *cobra.Command {
|
||||||
|
supportedTypes, typeDescriptions := unlockerAddHelp()
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "add <type>",
|
Use: "add <type>",
|
||||||
Short: "Add a new unlocker",
|
Short: "Add a new unlocker",
|
||||||
Long: fmt.Sprintf(`Add a new unlocker to the current vault.
|
Long: "Add a new unlocker to the current vault.\n" +
|
||||||
|
"\n" +
|
||||||
%s
|
typeDescriptions + "\n" +
|
||||||
|
"\n" +
|
||||||
Each vault can have multiple unlockers, allowing different authentication methods
|
"Each vault can have multiple unlockers, allowing different " +
|
||||||
to access the same vault. This provides flexibility and backup access options.`, typeDescriptions),
|
"authentication methods\n" +
|
||||||
|
"to access the same vault. This provides flexibility and " +
|
||||||
|
"backup access options.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgs: strings.Split(supportedTypes, ", "),
|
ValidArgs: strings.Split(supportedTypes, ", "),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -166,33 +230,28 @@ to access the same vault. This provides flexibility and backup access options.`,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerType := args[0]
|
unlockerType := args[0]
|
||||||
|
|
||||||
// Validate unlocker type
|
// Validate unlocker type
|
||||||
validTypes := strings.Split(supportedTypes, ", ")
|
validTypes := strings.Split(supportedTypes, ", ")
|
||||||
valid := false
|
if !slices.Contains(validTypes, unlockerType) {
|
||||||
for _, t := range validTypes {
|
return fmt.Errorf("%w '%s'\n\nSupported types: %s\n\n"+
|
||||||
if unlockerType == t {
|
"Run 'secret unlocker add --help' for detailed descriptions",
|
||||||
valid = true
|
errInvalidUnlockerType, unlockerType, supportedTypes)
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !valid {
|
|
||||||
return fmt.Errorf("invalid unlocker type '%s'\n\nSupported types: %s\n\n"+
|
|
||||||
"Run 'secret unlocker add --help' for detailed descriptions", unlockerType, supportedTypes)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if --keyid was used with non-PGP type
|
// Check if --keyid was used with non-PGP type
|
||||||
if unlockerType != "pgp" && cmd.Flags().Changed("keyid") {
|
if unlockerType != unlockerTypePGP && cmd.Flags().Changed("keyid") {
|
||||||
return fmt.Errorf("--keyid flag is only valid for PGP unlockers")
|
return errKeyIDOnlyForPGP
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.UnlockersAdd(unlockerType, cmd)
|
return cli.UnlockersAdd(unlockerType, cmd)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().String("keyid", "", "GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
cmd.Flags().String("keyid", "",
|
||||||
|
"GPG key ID for PGP unlockers (optional, uses default key if not specified)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -202,17 +261,20 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <unlocker-id>",
|
Use: "remove <unlocker-id>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove an unlocker",
|
Short: "Remove an unlocker",
|
||||||
Long: `Remove an unlocker from the current vault. Cannot remove the last unlocker if the vault has ` +
|
Long: `Remove an unlocker from the current vault. Cannot remove ` +
|
||||||
`secrets unless --force is used. Warning: Without unlockers and without your mnemonic, vault data ` +
|
`the last unlocker if the vault has secrets unless --force is ` +
|
||||||
`will be permanently inaccessible.`,
|
`used. Warning: Without unlockers and without your mnemonic, ` +
|
||||||
|
`vault data will be permanently inaccessible.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
@@ -222,7 +284,8 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolP("force", "f", false, "Force removal of last unlocker even if vault has secrets")
|
cmd.Flags().BoolP("force", "f", false,
|
||||||
|
"Force removal of last unlocker even if vault has secrets")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -249,6 +312,92 @@ func newUnlockerSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
||||||
|
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
||||||
|
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
||||||
|
func unlockerIDFromDir(
|
||||||
|
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
||||||
|
includeSecureEnclave bool,
|
||||||
|
) string {
|
||||||
|
// Create the appropriate unlocker instance
|
||||||
|
var unlocker secret.Unlocker
|
||||||
|
|
||||||
|
switch metadata.Type {
|
||||||
|
case unlockerTypePassphrase:
|
||||||
|
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
||||||
|
case unlockerTypeKeychain:
|
||||||
|
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
||||||
|
case unlockerTypePGP:
|
||||||
|
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||||
|
case unlockerTypeSecureEnclave:
|
||||||
|
if includeSecureEnclave {
|
||||||
|
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unlocker == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlocker.GetID()
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
||||||
|
// stored metadata matches the given type and creation time and returns
|
||||||
|
// the matching unlocker's ID. It returns ("", nil) when the directory is
|
||||||
|
// readable but holds no match, and a non-nil error when the directory
|
||||||
|
// itself cannot be read. Callers must distinguish the two: an unreadable
|
||||||
|
// directory means the unlocker's real ID is unknowable, so the entry has
|
||||||
|
// to be skipped rather than reported under a synthesized ID.
|
||||||
|
func findUnlockerIDByMetadata(
|
||||||
|
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
||||||
|
includeSecureEnclave bool,
|
||||||
|
) (string, error) {
|
||||||
|
files, err := afero.ReadDir(fs, unlockersDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
// Check if this is the right unlocker by comparing metadata
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Could not read unlocker metadata file",
|
||||||
|
"path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var diskMetadata secret.UnlockerMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Could not parse unlocker metadata file",
|
||||||
|
"path", metadataPath, "error", err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by type and creation time
|
||||||
|
if diskMetadata.Type == metadata.Type &&
|
||||||
|
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
||||||
|
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
||||||
|
includeSecureEnclave), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnlockersList lists unlockers in the current vault
|
// UnlockersList lists unlockers in the current vault
|
||||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
@@ -259,6 +408,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
|
|
||||||
// Get the current unlocker ID
|
// Get the current unlocker ID
|
||||||
var currentUnlockerID string
|
var currentUnlockerID string
|
||||||
|
|
||||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
currentUnlockerID = currentUnlocker.GetID()
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
@@ -272,74 +422,40 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
// Load actual unlocker objects to get the proper IDs
|
||||||
var unlockers []UnlockerInfo
|
var unlockers []UnlockerInfo
|
||||||
|
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, metadata := range unlockerMetadataList {
|
||||||
// Create unlocker instance to get the proper ID
|
// Create unlocker instance to get the proper ID
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not get vault directory while listing unlockers", "error", err)
|
secret.Warn("Could not get vault directory while listing unlockers",
|
||||||
|
"error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the unlocker directory by type and created time
|
// Find the unlocker directory by type and created time
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
|
||||||
|
unlockerID, err := findUnlockerIDByMetadata(
|
||||||
|
cli.fs, unlockersDir, metadata, true,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlockers directory", "error", err)
|
secret.Warn("Could not read unlockers directory, skipping unlocker",
|
||||||
|
"unlockers_dir", unlockersDir, "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var unlocker secret.Unlocker
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
// Check if this is the right unlocker by comparing metadata
|
|
||||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
|
||||||
secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match by type and creation time
|
|
||||||
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
||||||
// Create the appropriate unlocker instance
|
|
||||||
switch metadata.Type {
|
|
||||||
case "passphrase":
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
case "keychain":
|
|
||||||
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
case "pgp":
|
|
||||||
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
case "secure-enclave":
|
|
||||||
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the proper ID using the unlocker's ID() method
|
// Get the proper ID using the unlocker's ID() method
|
||||||
var properID string
|
var properID string
|
||||||
if unlocker != nil {
|
if unlockerID != "" {
|
||||||
properID = unlocker.GetID()
|
properID = unlockerID
|
||||||
} else {
|
} else {
|
||||||
// Generate ID as fallback
|
// Generate ID as fallback
|
||||||
properID = fmt.Sprintf("%s-%s", metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
properID = fmt.Sprintf("%s-%s",
|
||||||
secret.Warn("Could not create unlocker instance, using fallback ID", "fallback_id", properID, "type", metadata.Type)
|
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
||||||
|
secret.Warn("Could not create unlocker instance, using fallback ID",
|
||||||
|
"fallback_id", properID, "type", metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerInfo := UnlockerInfo{
|
unlockerInfo := UnlockerInfo{
|
||||||
@@ -360,8 +476,10 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// printUnlockersJSON prints unlockers in JSON format
|
// printUnlockersJSON prints unlockers in JSON format
|
||||||
func (cli *Instance) printUnlockersJSON(unlockers []UnlockerInfo, currentUnlockerID string) error {
|
func (cli *Instance) printUnlockersJSON(
|
||||||
output := map[string]interface{}{
|
unlockers []UnlockerInfo, currentUnlockerID string,
|
||||||
|
) error {
|
||||||
|
output := map[string]any{
|
||||||
"unlockers": unlockers,
|
"unlockers": unlockers,
|
||||||
"currentUnlockerID": currentUnlockerID,
|
"currentUnlockerID": currentUnlockerID,
|
||||||
}
|
}
|
||||||
@@ -395,10 +513,12 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
|||||||
if len(unlocker.Flags) > 0 {
|
if len(unlocker.Flags) > 0 {
|
||||||
flags = strings.Join(unlocker.Flags, ",")
|
flags = strings.Join(unlocker.Flags, ",")
|
||||||
}
|
}
|
||||||
|
|
||||||
prefix := " "
|
prefix := " "
|
||||||
if unlocker.IsCurrent {
|
if unlocker.IsCurrent {
|
||||||
prefix = "* "
|
prefix = "* "
|
||||||
}
|
}
|
||||||
|
|
||||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||||
prefix,
|
prefix,
|
||||||
unlocker.ID,
|
unlocker.ID,
|
||||||
@@ -414,164 +534,186 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
|||||||
|
|
||||||
// UnlockersAdd adds a new unlocker
|
// UnlockersAdd adds a new unlocker
|
||||||
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error {
|
||||||
// Build the supported types list based on platform
|
|
||||||
supportedTypes := "passphrase, pgp"
|
|
||||||
if runtime.GOOS == "darwin" {
|
|
||||||
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
|
||||||
}
|
|
||||||
|
|
||||||
switch unlockerType {
|
switch unlockerType {
|
||||||
case "passphrase":
|
case unlockerTypePassphrase:
|
||||||
// Get current vault
|
return cli.addPassphraseUnlocker(cmd)
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
case unlockerTypeKeychain:
|
||||||
if err != nil {
|
return cli.addKeychainUnlocker(cmd)
|
||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
case unlockerTypeSecureEnclave:
|
||||||
}
|
return cli.addSecureEnclaveUnlocker(cmd)
|
||||||
|
case unlockerTypePGP:
|
||||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
return cli.addPGPUnlocker(cmd)
|
||||||
// The CreatePassphraseUnlocker method will handle getting the long-term key
|
|
||||||
|
|
||||||
// Check if passphrase is set in environment variable
|
|
||||||
var passphraseBuffer *memguard.LockedBuffer
|
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
|
||||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
|
||||||
} else {
|
|
||||||
// Use secure passphrase input with confirmation
|
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
|
||||||
|
|
||||||
// Auto-select the newly created unlocker
|
|
||||||
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil {
|
|
||||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
|
||||||
} else {
|
|
||||||
cmd.Printf("Automatically selected as current unlocker\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "keychain":
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return fmt.Errorf("keychain unlockers are only supported on macOS")
|
|
||||||
}
|
|
||||||
|
|
||||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
|
||||||
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
|
|
||||||
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-select the newly created unlocker
|
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
|
||||||
}
|
|
||||||
if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil {
|
|
||||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
|
||||||
} else {
|
|
||||||
cmd.Printf("Automatically selected as current unlocker\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "secure-enclave":
|
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return fmt.Errorf("secure enclave unlockers are only supported on macOS")
|
|
||||||
}
|
|
||||||
|
|
||||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := vlt.SelectUnlocker(seUnlocker.GetID()); err != nil {
|
|
||||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
|
||||||
} else {
|
|
||||||
cmd.Printf("Automatically selected as current unlocker\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
case "pgp":
|
|
||||||
// Get GPG key ID from flag, environment, or default key
|
|
||||||
var gpgKeyID string
|
|
||||||
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
|
||||||
gpgKeyID = flagKeyID
|
|
||||||
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
|
||||||
gpgKeyID = envKeyID
|
|
||||||
} else {
|
|
||||||
// Try to get the default GPG key
|
|
||||||
defaultKeyID, err := getDefaultGPGKey()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
|
||||||
}
|
|
||||||
gpgKeyID = defaultKeyID
|
|
||||||
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this key is already added as an unlocker
|
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve the GPG key ID to its fingerprint
|
|
||||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this GPG key is already added
|
|
||||||
expectedID := fmt.Sprintf("pgp-%s", fingerprint)
|
|
||||||
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
|
|
||||||
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID)
|
|
||||||
}
|
|
||||||
|
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
|
||||||
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
|
||||||
|
|
||||||
// Auto-select the newly created unlocker
|
|
||||||
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil {
|
|
||||||
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
|
||||||
} else {
|
|
||||||
cmd.Printf("Automatically selected as current unlocker\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
|
// Build the supported types list based on platform
|
||||||
|
supportedTypes := "passphrase, pgp"
|
||||||
|
if runtime.GOOS == platformDarwin {
|
||||||
|
supportedTypes = "passphrase, keychain, pgp, secure-enclave"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%w: %s (supported: %s)",
|
||||||
|
errUnsupportedUnlockerType, unlockerType, supportedTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoSelectUnlocker selects the newly created unlocker as current,
|
||||||
|
// printing a warning if selection fails
|
||||||
|
func autoSelectUnlocker(cmd *cobra.Command, vlt *vault.Vault, unlockerID string) {
|
||||||
|
err := vlt.SelectUnlocker(unlockerID)
|
||||||
|
if err != nil {
|
||||||
|
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
|
||||||
|
} else {
|
||||||
|
cmd.Printf("Automatically selected as current unlocker\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPassphraseUnlocker creates a passphrase unlocker in the current vault
|
||||||
|
func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
||||||
|
// Get current vault
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||||
|
// The CreatePassphraseUnlocker method will handle getting the
|
||||||
|
// long-term key
|
||||||
|
|
||||||
|
// Check if passphrase is set in environment variable
|
||||||
|
var passphraseBuffer *memguard.LockedBuffer
|
||||||
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
|
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
||||||
|
} else {
|
||||||
|
// Use secure passphrase input with confirmation
|
||||||
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
|
// Auto-select the newly created unlocker
|
||||||
|
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
|
||||||
|
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
|
||||||
|
if runtime.GOOS != platformDarwin {
|
||||||
|
return errKeychainMacOSOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
|
||||||
|
|
||||||
|
keyName, err := keychainUnlocker.GetKeychainItemName()
|
||||||
|
if err == nil {
|
||||||
|
cmd.Printf("Keychain Item Name: %s\n", keyName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select the newly created unlocker
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
|
||||||
|
// current vault
|
||||||
|
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
|
||||||
|
if runtime.GOOS != platformDarwin {
|
||||||
|
return errSecureEnclaveMacOSOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
|
||||||
|
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoSelectUnlocker(cmd, vlt, seUnlocker.GetID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// addPGPUnlocker creates a PGP unlocker in the current vault
|
||||||
|
func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
||||||
|
// Get GPG key ID from flag, environment, or default key
|
||||||
|
var gpgKeyID string
|
||||||
|
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
|
||||||
|
gpgKeyID = flagKeyID
|
||||||
|
} else if envKeyID := os.Getenv(secret.EnvGPGKeyID); envKeyID != "" {
|
||||||
|
gpgKeyID = envKeyID
|
||||||
|
} else {
|
||||||
|
// Try to get the default GPG key
|
||||||
|
defaultKeyID, err := getDefaultGPGKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no GPG key specified and no default key found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gpgKeyID = defaultKeyID
|
||||||
|
cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this key is already added as an unlocker
|
||||||
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the GPG key ID to its fingerprint
|
||||||
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this GPG key is already added
|
||||||
|
expectedID := "pgp-" + fingerprint
|
||||||
|
|
||||||
|
err = cli.checkUnlockerExists(vlt, expectedID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
||||||
|
}
|
||||||
|
|
||||||
|
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Created PGP unlocker: %s\n", pgpUnlocker.GetID())
|
||||||
|
cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
|
||||||
|
|
||||||
|
// Auto-select the newly created unlocker
|
||||||
|
autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnlockersRemove removes an unlocker with safety checks
|
// UnlockersRemove removes an unlocker with safety checks
|
||||||
func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.Command) error {
|
func (cli *Instance) UnlockersRemove(
|
||||||
|
unlockerID string, force bool, cmd *cobra.Command,
|
||||||
|
) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -593,20 +735,24 @@ func (cli *Instance) UnlockersRemove(unlockerID string, force bool, cmd *cobra.C
|
|||||||
}
|
}
|
||||||
|
|
||||||
if numSecrets > 0 && !force {
|
if numSecrets > 0 && !force {
|
||||||
cmd.Println("ERROR: Cannot remove the last unlocker when the vault contains secrets.")
|
cmd.Println("ERROR: Cannot remove the last unlocker when the " +
|
||||||
cmd.Println("WARNING: Without unlockers, you MUST have your mnemonic phrase to decrypt the vault.")
|
"vault contains secrets.")
|
||||||
|
cmd.Println("WARNING: Without unlockers, you MUST have your " +
|
||||||
|
"mnemonic phrase to decrypt the vault.")
|
||||||
cmd.Println("If you want to proceed anyway, use --force")
|
cmd.Println("If you want to proceed anyway, use --force")
|
||||||
|
|
||||||
return fmt.Errorf("refusing to remove last unlocker")
|
return errLastUnlocker
|
||||||
}
|
}
|
||||||
|
|
||||||
if numSecrets > 0 && force {
|
if numSecrets > 0 && force {
|
||||||
cmd.Println("WARNING: Removing the last unlocker. You MUST have your mnemonic phrase to access this vault again!")
|
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
|
||||||
|
"have your mnemonic phrase to access this vault again!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the unlocker
|
// Remove the unlocker
|
||||||
if err := vlt.RemoveUnlocker(unlockerID); err != nil {
|
err = vlt.RemoveUnlocker(unlockerID)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,65 +785,29 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
|
|||||||
// Get vault directory to construct unlocker instances
|
// Get vault directory to construct unlocker instances
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not get vault directory during duplicate check", "error", err)
|
secret.Warn("Could not get vault directory during duplicate check",
|
||||||
|
"error", err)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each unlocker's ID
|
// Check each unlocker's ID
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
for _, metadata := range unlockers {
|
for _, metadata := range unlockers {
|
||||||
// Construct the unlocker based on type to get its ID
|
// Construct the unlocker matching this metadata to get its ID
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
||||||
files, err := afero.ReadDir(cli.fs, unlockersDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
|
secret.Warn(
|
||||||
|
"Could not read unlockers directory during duplicate check, "+
|
||||||
|
"skipping unlocker",
|
||||||
|
"unlockers_dir", unlockersDir, "error", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
if id != "" && id == unlockerID {
|
||||||
if !file.IsDir() {
|
return errUnlockerExists
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
// Check if this matches our metadata
|
|
||||||
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
|
|
||||||
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match by type and creation time
|
|
||||||
if diskMetadata.Type == metadata.Type && diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
||||||
var unlocker secret.Unlocker
|
|
||||||
switch metadata.Type {
|
|
||||||
case "passphrase":
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
case "keychain":
|
|
||||||
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
case "pgp":
|
|
||||||
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
case "secure-enclave":
|
|
||||||
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
if unlocker != nil && unlocker.GetID() == unlockerID {
|
|
||||||
return fmt.Errorf("unlocker already exists")
|
|
||||||
}
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
229
internal/cli/unlockers_list_test.go
Normal file
229
internal/cli/unlockers_list_test.go
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
// Unlocker List Tests
|
||||||
|
//
|
||||||
|
// Tests for `secret unlocker list` behavior when the unlockers.d directory
|
||||||
|
// cannot be read while the listing is being rendered:
|
||||||
|
//
|
||||||
|
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
||||||
|
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
||||||
|
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
||||||
|
// still listed, with its real ID and its current-unlocker marker,
|
||||||
|
// when a later entry's scan fails.
|
||||||
|
//
|
||||||
|
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
||||||
|
// after the vault has already enumerated it. If that rescan fails the ID
|
||||||
|
// is unknowable, so the entry must be skipped: a synthesized ID matches
|
||||||
|
// no `unlocker remove` or `unlocker select` argument and would also
|
||||||
|
// suppress the current-unlocker marker.
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// listTestStateDir is the state directory of the synthetic vault used
|
||||||
|
// by the unlocker listing tests.
|
||||||
|
listTestStateDir = "/state"
|
||||||
|
|
||||||
|
// listTestVaultName is the name of that synthetic vault.
|
||||||
|
listTestVaultName = "default"
|
||||||
|
|
||||||
|
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
|
||||||
|
// unlocker's metadata. The unlocker's real ID is derived from it, and
|
||||||
|
// differs from the timestamp-derived fallback ID.
|
||||||
|
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
||||||
|
|
||||||
|
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
||||||
|
// directory names under unlockers.d.
|
||||||
|
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||||
|
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||||
|
|
||||||
|
// listTestUnlockersDirName is the directory the listing rescans to
|
||||||
|
// resolve unlocker IDs.
|
||||||
|
listTestUnlockersDirName = "unlockers.d"
|
||||||
|
|
||||||
|
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||||
|
listTestMetadataFileName = "unlocker-metadata.json"
|
||||||
|
|
||||||
|
// listTestDirPerm and listTestFilePerm are the fixture permissions.
|
||||||
|
listTestDirPerm = 0o700
|
||||||
|
listTestFilePerm = 0o600
|
||||||
|
)
|
||||||
|
|
||||||
|
// errUnlockersDirUnreadable is returned by the test filesystem in place of
|
||||||
|
// a successful open of unlockers.d.
|
||||||
|
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||||
|
|
||||||
|
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
|
||||||
|
// successfully openBudget times. This reproduces the directory becoming
|
||||||
|
// unreadable (permission change, partially restored backup, EIO) between
|
||||||
|
// the vault's own enumeration and the per-entry rescan that resolves
|
||||||
|
// unlocker IDs.
|
||||||
|
type unlockersDirFailFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
openBudget int
|
||||||
|
opens int
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
|
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||||
|
if filepath.Base(name) == listTestUnlockersDirName {
|
||||||
|
f.opens++
|
||||||
|
if f.opens > f.openBudget {
|
||||||
|
return nil, errUnlockersDirUnreadable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||||
|
return f.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
||||||
|
// yields the real ID "pgp-<keyID>".
|
||||||
|
func writePGPUnlocker(
|
||||||
|
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
||||||
|
createdAt time.Time, keyID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
metadata := secret.PGPUnlockerMetadata{
|
||||||
|
UnlockerMetadata: secret.UnlockerMetadata{
|
||||||
|
Type: unlockerTypePGP,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
},
|
||||||
|
GPGKeyID: keyID,
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded, err := json.Marshal(metadata)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dir := filepath.Join(unlockersDir, dirName)
|
||||||
|
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
||||||
|
require.NoError(t, afero.WriteFile(
|
||||||
|
fs, filepath.Join(dir, listTestMetadataFileName), encoded,
|
||||||
|
listTestFilePerm,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newListTestVault builds a synthetic vault on a MemMapFs containing the
|
||||||
|
// given number of PGP unlockers, with the first one selected as current.
|
||||||
|
func newListTestVault(t *testing.T, unlockerCount int) *afero.MemMapFs {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
base := &afero.MemMapFs{}
|
||||||
|
vaultDir := filepath.Join(listTestStateDir, "vaults.d", listTestVaultName)
|
||||||
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(
|
||||||
|
base, filepath.Join(listTestStateDir, "currentvault"),
|
||||||
|
[]byte(listTestVaultName), listTestFilePerm,
|
||||||
|
))
|
||||||
|
|
||||||
|
names := []string{listTestUnlockerDirOne, listTestUnlockerDirTwo}
|
||||||
|
names = names[:unlockerCount]
|
||||||
|
|
||||||
|
for i, name := range names {
|
||||||
|
writePGPUnlocker(t, base, unlockersDir, name,
|
||||||
|
time.Date(2026, time.August, 9+i, 12, 30, 0, 0, time.UTC),
|
||||||
|
listTestGPGKeyID+string(rune('A'+i)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(
|
||||||
|
base, filepath.Join(vaultDir, "current-unlocker"),
|
||||||
|
[]byte(names[0]), listTestFilePerm,
|
||||||
|
))
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUnlockersJSON runs UnlockersList in JSON mode against the given
|
||||||
|
// filesystem and decodes the emitted unlocker rows.
|
||||||
|
func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(&buf)
|
||||||
|
cmd.SetErr(&buf)
|
||||||
|
|
||||||
|
instance := &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}
|
||||||
|
require.NoError(t, instance.UnlockersList(true))
|
||||||
|
|
||||||
|
var decoded struct {
|
||||||
|
Unlockers []UnlockerInfo `json:"unlockers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
|
||||||
|
|
||||||
|
return decoded.Unlockers
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
|
||||||
|
// which becomes unreadable after the vault enumerated it produces no rows,
|
||||||
|
// rather than rows carrying fabricated fallback IDs.
|
||||||
|
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
// Budget of one: the vault's own ListUnlockers scan succeeds, the
|
||||||
|
// per-entry rescan that resolves the ID fails.
|
||||||
|
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, fs)
|
||||||
|
|
||||||
|
assert.Empty(t, unlockers,
|
||||||
|
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
|
||||||
|
// entry survives with its real ID and current-unlocker marker when a later
|
||||||
|
// entry's rescan fails.
|
||||||
|
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 2)
|
||||||
|
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
|
||||||
|
// the second entry's rescan fails.
|
||||||
|
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, fs)
|
||||||
|
|
||||||
|
require.Len(t, unlockers, 1,
|
||||||
|
"only the entry whose directory was readable may be listed")
|
||||||
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
|
||||||
|
"the surviving row must carry the real unlocker ID")
|
||||||
|
assert.True(t, unlockers[0].IsCurrent,
|
||||||
|
"the current-unlocker marker must survive the skip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||||
|
// fully readable unlockers.d every entry is listed with its real ID.
|
||||||
|
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 2)
|
||||||
|
|
||||||
|
unlockers := listUnlockersJSON(t, base)
|
||||||
|
|
||||||
|
require.Len(t, unlockers, 2)
|
||||||
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
|
||||||
|
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
|
||||||
|
assert.True(t, unlockers[0].IsCurrent)
|
||||||
|
assert.False(t, unlockers[1].IsCurrent)
|
||||||
|
}
|
||||||
@@ -2,10 +2,12 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,6 +20,22 @@ import (
|
|||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for vault operations
|
||||||
|
var (
|
||||||
|
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||||
|
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||||
|
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
|
||||||
|
errVaultHasLongTermKey = errors.New(
|
||||||
|
"already has a long-term key configured")
|
||||||
|
errMnemonicEnvNotSet = errors.New(
|
||||||
|
"SB_SECRET_MNEMONIC environment variable not set")
|
||||||
|
errPassphraseEnvNotSet = errors.New(
|
||||||
|
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||||
|
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||||
|
errVaultContainsSecrets = errors.New(
|
||||||
|
"contains secrets; use --force to remove")
|
||||||
|
)
|
||||||
|
|
||||||
func newVaultCmd() *cobra.Command {
|
func newVaultCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "vault",
|
Use: "vault",
|
||||||
@@ -36,7 +54,7 @@ func newVaultCmd() *cobra.Command {
|
|||||||
|
|
||||||
func newVaultListCmd() *cobra.Command {
|
func newVaultListCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "list",
|
Use: cmdUseList,
|
||||||
Aliases: []string{"ls"},
|
Aliases: []string{"ls"},
|
||||||
Short: "List available vaults",
|
Short: "List available vaults",
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
@@ -101,9 +119,10 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "import <vault-name>",
|
Use: "import <vault-name>",
|
||||||
Short: "Import a mnemonic into a vault",
|
Short: "Import a mnemonic into a vault",
|
||||||
Long: `Import a BIP39 mnemonic phrase into the specified vault (default if not specified).`,
|
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
||||||
|
`(default if not specified).`,
|
||||||
Args: cobra.MaximumNArgs(1),
|
Args: cobra.MaximumNArgs(1),
|
||||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -127,16 +146,19 @@ func newVaultRemoveCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "remove <name>",
|
Use: "remove <name>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a vault",
|
Short: "Remove a vault",
|
||||||
Long: `Remove a vault. Requires --force if the vault contains secrets. Will automatically ` +
|
Long: `Remove a vault. Requires --force if the vault contains ` +
|
||||||
`switch to another vault if removing the currently selected one.`,
|
`secrets. Will automatically switch to another vault if ` +
|
||||||
|
`removing the currently selected one.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
force, _ := cmd.Flags().GetBool("force")
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
@@ -161,11 +183,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||||
// Get current vault name for context
|
// Get current vault name for context
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err == nil {
|
||||||
currentVault = currentVlt.GetName()
|
currentVault = currentVlt.GetName()
|
||||||
}
|
}
|
||||||
|
|
||||||
result := map[string]interface{}{
|
result := map[string]any{
|
||||||
"vaults": vaults,
|
"vaults": vaults,
|
||||||
"currentVault": currentVault,
|
"currentVault": currentVault,
|
||||||
}
|
}
|
||||||
@@ -174,16 +198,20 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Println(string(jsonBytes))
|
cmd.Println(string(jsonBytes))
|
||||||
} else {
|
} else {
|
||||||
// Text output
|
// Text output
|
||||||
cmd.Println("Available vaults:")
|
cmd.Println("Available vaults:")
|
||||||
|
|
||||||
if len(vaults) == 0 {
|
if len(vaults) == 0 {
|
||||||
cmd.Println(" (none)")
|
cmd.Println(" (none)")
|
||||||
} else {
|
} else {
|
||||||
// Try to get current vault for marking
|
// Try to get current vault for marking
|
||||||
currentVault := ""
|
currentVault := ""
|
||||||
if currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir); err == nil {
|
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err == nil {
|
||||||
currentVault = currentVlt.GetName()
|
currentVault = currentVlt.GetName()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,19 +228,57 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setMnemonicEnv sets the mnemonic environment variable and returns a
|
||||||
|
// function that restores the previous value
|
||||||
|
func setMnemonicEnv(mnemonicStr string) func() {
|
||||||
|
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if originalMnemonic != "" {
|
||||||
|
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
||||||
|
} else {
|
||||||
|
_ = os.Unsetenv(secret.EnvMnemonic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePassphrase returns the unlock passphrase from the environment or
|
||||||
|
// prompts the user for it with confirmation
|
||||||
|
func resolvePassphrase() (*memguard.LockedBuffer, error) {
|
||||||
|
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
||||||
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
|
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Prompting user for unlock passphrase")
|
||||||
|
|
||||||
|
// Use secure passphrase input with confirmation
|
||||||
|
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return passphraseBuffer, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault
|
// CreateVault creates a new vault
|
||||||
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
// Get or prompt for mnemonic
|
// Get or prompt for mnemonic
|
||||||
var mnemonicStr string
|
var mnemonicStr string
|
||||||
|
|
||||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
secret.Debug("Using mnemonic from environment variable")
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
|
|
||||||
mnemonicStr = envMnemonic
|
mnemonicStr = envMnemonic
|
||||||
} else {
|
} else {
|
||||||
secret.Debug("Prompting user for mnemonic phrase")
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
// Read mnemonic securely without echo
|
// Read mnemonic securely without echo
|
||||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
mnemonicBuffer, err := secret.ReadPassphrase(
|
||||||
|
"Enter your BIP39 mnemonic phrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||||
|
|
||||||
@@ -221,30 +287,25 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
defer mnemonicBuffer.Destroy()
|
defer mnemonicBuffer.Destroy()
|
||||||
|
|
||||||
mnemonicStr = mnemonicBuffer.String()
|
mnemonicStr = mnemonicBuffer.String()
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
}
|
}
|
||||||
|
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
return fmt.Errorf("mnemonic cannot be empty")
|
return errMnemonicEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the mnemonic
|
// Validate the mnemonic
|
||||||
mnemonicWords := strings.Fields(mnemonicStr)
|
mnemonicWords := strings.Fields(mnemonicStr)
|
||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonicStr) {
|
if !bip39.IsMnemonicValid(mnemonicStr) {
|
||||||
return fmt.Errorf("invalid BIP39 mnemonic phrase")
|
return errInvalidMnemonicPhrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mnemonic in environment for CreateVault to use
|
// Set mnemonic in environment for CreateVault to use
|
||||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
||||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
defer restoreMnemonicEnv()
|
||||||
defer func() {
|
|
||||||
if originalMnemonic != "" {
|
|
||||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
|
||||||
} else {
|
|
||||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Create the vault - it will handle key derivation internally
|
// Create the vault - it will handle key derivation internally
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
||||||
@@ -254,6 +315,7 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
// Get the vault metadata to retrieve the derivation index
|
||||||
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
||||||
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
return fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
@@ -269,22 +331,15 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
// Get or prompt for passphrase
|
// Get or prompt for passphrase
|
||||||
var passphraseBuffer *memguard.LockedBuffer
|
passphraseBuffer, err := resolvePassphrase()
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
if err != nil {
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
return err
|
||||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
|
||||||
} else {
|
|
||||||
secret.Debug("Prompting user for unlock passphrase")
|
|
||||||
// Use secure passphrase input with confirmation
|
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
@@ -299,7 +354,8 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
|
|
||||||
// SelectVault selects a vault as the current one
|
// SelectVault selects a vault as the current one
|
||||||
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||||
if err := vault.SelectVault(cli.fs, cli.stateDir, name); err != nil {
|
err := vault.SelectVault(cli.fs, cli.stateDir, name)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,84 +364,60 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VaultImport imports a mnemonic into a specific vault
|
// vaultImportPreflight verifies the vault exists without a long-term key
|
||||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
// and returns the vault directory, public key path, and validated mnemonic
|
||||||
secret.Debug("Importing mnemonic into vault", "vault_name", vaultName, "state_dir", cli.stateDir)
|
func (cli *Instance) vaultImportPreflight(
|
||||||
|
vlt *vault.Vault, vaultName string,
|
||||||
// Get the specific vault by name
|
) (string, string, string, error) {
|
||||||
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
|
||||||
|
|
||||||
// Check if vault exists
|
// Check if vault exists
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
exists, err := afero.DirExists(cli.fs, vaultDir)
|
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("vault '%s' does not exist", vaultName)
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
|
vaultName, errVaultDoesNotExist)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault already has a public key
|
// Check if vault already has a public key
|
||||||
pubKeyPath := fmt.Sprintf("%s/pub.age", vaultDir)
|
pubKeyPath := vaultDir + "/pub.age"
|
||||||
if _, err := cli.fs.Stat(pubKeyPath); err == nil {
|
|
||||||
return fmt.Errorf("vault '%s' already has a long-term key configured", vaultName)
|
_, err = cli.fs.Stat(pubKeyPath)
|
||||||
|
if err == nil {
|
||||||
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
|
vaultName, errVaultHasLongTermKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get mnemonic from environment
|
// Get mnemonic from environment
|
||||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
if mnemonic == "" {
|
if mnemonic == "" {
|
||||||
return fmt.Errorf("SB_SECRET_MNEMONIC environment variable not set")
|
return "", "", "", errMnemonicEnvNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the mnemonic
|
// Validate the mnemonic
|
||||||
mnemonicWords := strings.Fields(mnemonic)
|
mnemonicWords := strings.Fields(mnemonic)
|
||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonic) {
|
if !bip39.IsMnemonicValid(mnemonic) {
|
||||||
return fmt.Errorf("invalid BIP39 mnemonic")
|
return "", "", "", errInvalidMnemonic
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
return vaultDir, pubKeyPath, mnemonic, nil
|
||||||
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
}
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to get next derivation index", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to get next derivation index: %w", err)
|
|
||||||
}
|
|
||||||
secret.Debug("Using derivation index", "index", derivationIndex)
|
|
||||||
|
|
||||||
// Derive long-term key from mnemonic with the appropriate index
|
|
||||||
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to derive long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store long-term public key in vault
|
|
||||||
ltPublicKey := ltIdentity.Recipient().String()
|
|
||||||
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir)
|
|
||||||
|
|
||||||
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms); err != nil {
|
|
||||||
return fmt.Errorf("failed to store long-term public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate public key hash from the actual derivation index being used
|
|
||||||
// This is used to verify that the derived key matches what was stored
|
|
||||||
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
|
||||||
|
|
||||||
// Calculate family hash from index 0 (same for all vaults with this mnemonic)
|
|
||||||
// This is used to identify which vaults belong to the same mnemonic family
|
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
|
||||||
}
|
|
||||||
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
|
||||||
|
|
||||||
|
// updateVaultImportMetadata stores the derivation info in vault metadata
|
||||||
|
func updateVaultImportMetadata(
|
||||||
|
fs afero.Fs, vaultDir string, derivationIndex uint32,
|
||||||
|
publicKeyHash, familyHash string,
|
||||||
|
) error {
|
||||||
// Load existing metadata
|
// Load existing metadata
|
||||||
existingMetadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If metadata doesn't exist, create new
|
// If metadata doesn't exist, create new
|
||||||
existingMetadata = &vault.Metadata{
|
existingMetadata = &vault.Metadata{
|
||||||
@@ -398,17 +430,83 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
existingMetadata.PublicKeyHash = publicKeyHash
|
existingMetadata.PublicKeyHash = publicKeyHash
|
||||||
existingMetadata.MnemonicFamilyHash = familyHash
|
existingMetadata.MnemonicFamilyHash = familyHash
|
||||||
|
|
||||||
if err := vault.SaveVaultMetadata(cli.fs, vaultDir, existingMetadata); err != nil {
|
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
||||||
|
if err != nil {
|
||||||
secret.Debug("Failed to save vault metadata", "error", err)
|
secret.Debug("Failed to save vault metadata", "error", err)
|
||||||
|
|
||||||
return fmt.Errorf("failed to save vault metadata: %w", err)
|
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultImport imports a mnemonic into a specific vault
|
||||||
|
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||||
|
secret.Debug("Importing mnemonic into vault",
|
||||||
|
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
|
// Get the specific vault by name
|
||||||
|
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||||
|
|
||||||
|
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the next available derivation index for this mnemonic
|
||||||
|
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get next derivation index", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||||
|
|
||||||
|
// Derive long-term key from mnemonic with the appropriate index
|
||||||
|
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store long-term public key in vault
|
||||||
|
ltPublicKey := ltIdentity.Recipient().String()
|
||||||
|
secret.Debug("Storing long-term public key",
|
||||||
|
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||||
|
|
||||||
|
err = afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate public key hash from the actual derivation index being used
|
||||||
|
// This is used to verify that the derived key matches what was stored
|
||||||
|
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||||
|
|
||||||
|
// Calculate family hash from index 0 (same for all vaults with this
|
||||||
|
// mnemonic). This is used to identify which vaults belong to the same
|
||||||
|
// mnemonic family.
|
||||||
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
|
err = updateVaultImportMetadata(
|
||||||
|
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Get passphrase from environment variable
|
// Get passphrase from environment variable
|
||||||
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
||||||
if passphraseStr == "" {
|
if passphraseStr == "" {
|
||||||
return fmt.Errorf("SB_UNLOCK_PASSPHRASE environment variable not set")
|
return errPassphraseEnvNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
@@ -422,6 +520,7 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
|
|
||||||
// Create passphrase-protected unlocker
|
// Create passphrase-protected unlocker
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
@@ -436,6 +535,46 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vaultHasSecrets reports whether the vault directory contains any secrets
|
||||||
|
func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
|
exists, _ := afero.DirExists(cli.fs, secretsDir)
|
||||||
|
if !exists {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||||
|
|
||||||
|
return err == nil && len(entries) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// switchAwayFromVault selects another vault as current before removal
|
||||||
|
func (cli *Instance) switchAwayFromVault(
|
||||||
|
cmd *cobra.Command, vaults []string, name string,
|
||||||
|
) error {
|
||||||
|
// Find another vault to switch to
|
||||||
|
var newVault string
|
||||||
|
|
||||||
|
for _, v := range vaults {
|
||||||
|
if v != name {
|
||||||
|
newVault = v
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the new vault
|
||||||
|
err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveVault removes a vault with safety checks
|
// RemoveVault removes a vault with safety checks
|
||||||
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
// Get list of all vaults
|
// Get list of all vaults
|
||||||
@@ -445,21 +584,13 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault exists
|
// Check if vault exists
|
||||||
vaultExists := false
|
if !slices.Contains(vaults, name) {
|
||||||
for _, v := range vaults {
|
return fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
||||||
if v == name {
|
|
||||||
vaultExists = true
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !vaultExists {
|
|
||||||
return fmt.Errorf("vault '%s' does not exist", name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow removing the last vault
|
// Don't allow removing the last vault
|
||||||
if len(vaults) == 1 {
|
if len(vaults) == 1 {
|
||||||
return fmt.Errorf("cannot remove the last vault")
|
return errCannotRemoveLastVault
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is the current vault
|
// Check if this is the current vault
|
||||||
@@ -467,57 +598,44 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
isCurrentVault := currentVault.GetName() == name
|
isCurrentVault := currentVault.GetName() == name
|
||||||
|
|
||||||
// Load the vault to check for secrets
|
// Load the vault to check for secrets
|
||||||
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
|
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get vault directory: %w", err)
|
return fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault has secrets
|
// Check if vault has secrets
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
hasSecrets := cli.vaultHasSecrets(vaultDir)
|
||||||
hasSecrets := false
|
|
||||||
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists {
|
|
||||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
|
||||||
if err == nil && len(entries) > 0 {
|
|
||||||
hasSecrets = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require --force if vault has secrets
|
// Require --force if vault has secrets
|
||||||
if hasSecrets && !force {
|
if hasSecrets && !force {
|
||||||
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name)
|
return fmt.Errorf("vault '%s' %w", name, errVaultContainsSecrets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If removing current vault, switch to another vault first
|
// If removing current vault, switch to another vault first
|
||||||
if isCurrentVault {
|
if isCurrentVault {
|
||||||
// Find another vault to switch to
|
err = cli.switchAwayFromVault(cmd, vaults, name)
|
||||||
var newVault string
|
if err != nil {
|
||||||
for _, v := range vaults {
|
return err
|
||||||
if v != name {
|
|
||||||
newVault = v
|
|
||||||
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Switch to the new vault
|
|
||||||
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil {
|
|
||||||
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
|
|
||||||
}
|
|
||||||
cmd.Printf("Switched current vault to '%s'\n", newVault)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the vault directory
|
// Remove the vault directory
|
||||||
if err := cli.fs.RemoveAll(vaultDir); err != nil {
|
err = cli.fs.RemoveAll(vaultDir)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove vault directory: %w", err)
|
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Removed vault '%s'\n", name)
|
cmd.Printf("Removed vault '%s'\n", name)
|
||||||
|
|
||||||
if hasSecrets {
|
if hasSecrets {
|
||||||
cmd.Printf("Warning: Vault contained secrets that have been permanently deleted\n")
|
cmd.Printf("Warning: Vault contained secrets that have been " +
|
||||||
|
"permanently deleted\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -17,6 +21,12 @@ const (
|
|||||||
tabWriterPadding = 2
|
tabWriterPadding = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for version operations
|
||||||
|
var (
|
||||||
|
errVersionNotFound = errors.New("not found for secret")
|
||||||
|
errCannotRemoveCurrentVersion = errors.New("promote another version first")
|
||||||
|
)
|
||||||
|
|
||||||
// newVersionCmd returns the version management command
|
// newVersionCmd returns the version management command
|
||||||
func newVersionCmd() *cobra.Command {
|
func newVersionCmd() *cobra.Command {
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
@@ -32,7 +42,8 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
versionCmd := &cobra.Command{
|
versionCmd := &cobra.Command{
|
||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Manage secret versions",
|
Short: "Manage secret versions",
|
||||||
Long: "Commands for managing secret versions including listing, promoting, and retrieving specific versions",
|
Long: "Commands for managing secret versions including listing, " +
|
||||||
|
"promoting, and retrieving specific versions",
|
||||||
}
|
}
|
||||||
|
|
||||||
// List versions command
|
// List versions command
|
||||||
@@ -51,14 +62,17 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
promoteCmd := &cobra.Command{
|
promoteCmd := &cobra.Command{
|
||||||
Use: "promote <secret-name> <version>",
|
Use: "promote <secret-name> <version>",
|
||||||
Short: "Promote a specific version to current",
|
Short: "Promote a specific version to current",
|
||||||
Long: "Updates the current symlink to point to the specified version without modifying timestamps",
|
Long: "Updates the current symlink to point to the specified " +
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
"version without modifying timestamps",
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||||
|
ValidArgsFunction: func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
// Complete secret name for first arg
|
// Complete secret name for first arg
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
}
|
}
|
||||||
// TODO: Complete version numbers for second arg
|
// Version number completion for the second arg is not implemented
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -71,14 +85,17 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Use: "remove <secret-name> <version>",
|
Use: "remove <secret-name> <version>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a specific version of a secret",
|
Short: "Remove a specific version of a secret",
|
||||||
Long: "Remove a specific version of a secret. Cannot remove the current version.",
|
Long: "Remove a specific version of a secret. Cannot remove the " +
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // Command requires exactly 2 arguments: secret-name and version
|
"current version.",
|
||||||
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||||
|
ValidArgsFunction: func(
|
||||||
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
// Complete secret name for first arg
|
// Complete secret name for first arg
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
return getSecretNamesCompletionFunc(cli.fs, cli.stateDir)(cmd, args, toComplete)
|
||||||
}
|
}
|
||||||
// TODO: Complete version numbers for second arg
|
// Version number completion for the second arg is not implemented
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -121,10 +138,11 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
|
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secret not found", "secret_name", secretName)
|
secret.Debug("Secret not found", "secret_name", secretName)
|
||||||
|
|
||||||
return fmt.Errorf("secret '%s' not found", secretName)
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all versions
|
// List all versions
|
||||||
@@ -145,6 +163,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current version", "error", err)
|
secret.Debug("Failed to get current version", "error", err)
|
||||||
|
|
||||||
currentVersion = ""
|
currentVersion = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,44 +179,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
|
|
||||||
// Load and display each version's metadata
|
// Load and display each version's metadata
|
||||||
for _, version := range versions {
|
for _, version := range versions {
|
||||||
sv := secret.NewVersion(vlt, secretName, version)
|
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
|
||||||
|
|
||||||
// Load metadata
|
|
||||||
if err := sv.LoadMetadata(ltIdentity); err != nil {
|
|
||||||
secret.Warn("Failed to load version metadata", "version", version, "error", err)
|
|
||||||
// Display version with error
|
|
||||||
status := "error"
|
|
||||||
if version == currentVersion {
|
|
||||||
status = "current (error)"
|
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine status
|
|
||||||
status := "expired"
|
|
||||||
if version == currentVersion {
|
|
||||||
status = "current"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format timestamps
|
|
||||||
createdAt := "-"
|
|
||||||
if sv.Metadata.CreatedAt != nil {
|
|
||||||
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05")
|
|
||||||
}
|
|
||||||
|
|
||||||
notBefore := "-"
|
|
||||||
if sv.Metadata.NotBefore != nil {
|
|
||||||
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05")
|
|
||||||
}
|
|
||||||
|
|
||||||
notAfter := "-"
|
|
||||||
if sv.Metadata.NotAfter != nil {
|
|
||||||
notAfter = sv.Metadata.NotAfter.Format("2006-01-02 15:04:05")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, createdAt, status, notBefore, notAfter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = w.Flush()
|
_ = w.Flush()
|
||||||
@@ -205,8 +187,58 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// printVersionRow loads one version's metadata and writes its table row
|
||||||
|
func printVersionRow(
|
||||||
|
w io.Writer, vlt *vault.Vault,
|
||||||
|
secretName, version, currentVersion string,
|
||||||
|
ltIdentity *age.X25519Identity,
|
||||||
|
) {
|
||||||
|
sv := secret.NewVersion(vlt, secretName, version)
|
||||||
|
|
||||||
|
// Load metadata
|
||||||
|
err := sv.LoadMetadata(ltIdentity)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Failed to load version metadata",
|
||||||
|
"version", version, "error", err)
|
||||||
|
// Display version with error
|
||||||
|
status := "error"
|
||||||
|
if version == currentVersion {
|
||||||
|
status = "current (error)"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine status
|
||||||
|
status := "expired"
|
||||||
|
if version == currentVersion {
|
||||||
|
status = "current"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamps
|
||||||
|
createdAt := formatVersionTime(sv.Metadata.CreatedAt)
|
||||||
|
notBefore := formatVersionTime(sv.Metadata.NotBefore)
|
||||||
|
notAfter := formatVersionTime(sv.Metadata.NotAfter)
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
version, createdAt, status, notBefore, notAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatVersionTime formats an optional version timestamp, "-" when unset
|
||||||
|
func formatVersionTime(t *time.Time) string {
|
||||||
|
if t == nil {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
|
||||||
// PromoteVersion promotes a specific version to current
|
// PromoteVersion promotes a specific version to current
|
||||||
func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, version string) error {
|
func (cli *Instance) PromoteVersion(
|
||||||
|
cmd *cobra.Command, secretName string, version string,
|
||||||
|
) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -224,16 +256,20 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
|||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
versionDir := filepath.Join(secretDir, "versions", version)
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
|
||||||
exists, err := afero.DirExists(cli.fs, versionDir)
|
exists, err := afero.DirExists(cli.fs, versionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
return fmt.Errorf("version '%s' %w '%s'",
|
||||||
|
version, errVersionNotFound, secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the current symlink using the proper function
|
// Update the current symlink using the proper function
|
||||||
if err := secret.SetCurrentVersion(cli.fs, secretDir, version); err != nil {
|
err = secret.SetCurrentVersion(cli.fs, secretDir, version)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to update current version: %w", err)
|
return fmt.Errorf("failed to update current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,7 +279,9 @@ func (cli *Instance) PromoteVersion(cmd *cobra.Command, secretName string, versi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RemoveVersion removes a specific version of a secret
|
// RemoveVersion removes a specific version of a secret
|
||||||
func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, version string) error {
|
func (cli *Instance) RemoveVersion(
|
||||||
|
cmd *cobra.Command, secretName string, version string,
|
||||||
|
) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,18 +302,22 @@ func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, versio
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' not found", secretName)
|
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if version exists
|
// Check if version exists
|
||||||
versionDir := filepath.Join(secretDir, "versions", version)
|
versionDir := filepath.Join(secretDir, "versions", version)
|
||||||
|
|
||||||
exists, err = afero.DirExists(cli.fs, versionDir)
|
exists, err = afero.DirExists(cli.fs, versionDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if version exists: %w", err)
|
return fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("version '%s' not found for secret '%s'", version, secretName)
|
return fmt.Errorf("version '%s' %w '%s'",
|
||||||
|
version, errVersionNotFound, secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current version
|
// Get current version
|
||||||
@@ -286,11 +328,13 @@ func (cli *Instance) RemoveVersion(cmd *cobra.Command, secretName string, versio
|
|||||||
|
|
||||||
// Don't allow removing the current version
|
// Don't allow removing the current version
|
||||||
if version == currentVersion {
|
if version == currentVersion {
|
||||||
return fmt.Errorf("cannot remove the current version '%s'; promote another version first", version)
|
return fmt.Errorf("cannot remove the current version '%s'; %w",
|
||||||
|
version, errCannotRemoveCurrentVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the version directory
|
// Remove the version directory
|
||||||
if err := cli.fs.RemoveAll(versionDir); err != nil {
|
err = cli.fs.RemoveAll(versionDir)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove version: %w", err)
|
return fmt.Errorf("failed to remove version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
// - setupTestVault(): CLI test helper for vault initialization
|
// - setupTestVault(): CLI test helper for vault initialization
|
||||||
// - Uses consistent test mnemonic for reproducible testing
|
// - Uses consistent test mnemonic for reproducible testing
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -32,29 +33,41 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper function to add a secret to vault with proper buffer protection
|
const (
|
||||||
func addTestSecret(t *testing.T, vlt *vault.Vault, name string, value []byte, force bool) {
|
// testMnemonic is the standard BIP39 mnemonic used for CLI tests.
|
||||||
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||||
|
testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// testStateDir is the in-memory state directory used by CLI tests.
|
||||||
|
testStateDir = "/test/state"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to add a version of the "test/secret" secret to the
|
||||||
|
// vault with proper buffer protection
|
||||||
|
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes(value)
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
defer buffer.Destroy()
|
defer buffer.Destroy()
|
||||||
err := vlt.AddSecret(name, buffer, force)
|
|
||||||
|
err := vlt.AddSecret("test/secret", buffer, force)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to set up a vault with long-term key
|
// Helper function to set up a vault with long-term key in testStateDir
|
||||||
func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
// Set mnemonic for testing
|
// Set mnemonic for testing
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon " +
|
|
||||||
"abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "default")
|
vlt, err := vault.CreateVault(fs, testStateDir, "default")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Store long-term public key in vault
|
// Store long-term public key in vault
|
||||||
@@ -64,30 +77,32 @@ func setupTestVault(t *testing.T, fs afero.Fs, stateDir string) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Select vault
|
// Select vault
|
||||||
err = vault.SelectVault(fs, stateDir, "default")
|
err = vault.SelectVault(fs, testStateDir, "default")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestListVersionsCommand(t *testing.T) {
|
func TestListVersionsCommand(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs, stateDir)
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
// Add a secret with multiple versions
|
// Add a secret with multiple versions
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -112,24 +127,28 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
// Should have two version entries
|
// Should have two version entries
|
||||||
lines := strings.Split(outputStr, "\n")
|
lines := strings.Split(outputStr, "\n")
|
||||||
versionLines := 0
|
versionLines := 0
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
if strings.Contains(line, ".001") || strings.Contains(line, ".002") {
|
||||||
versionLines++
|
versionLines++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, 2, versionLines)
|
assert.Equal(t, 2, versionLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs, stateDir)
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -140,23 +159,24 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestPromoteVersionCommand(t *testing.T) {
|
func TestPromoteVersionCommand(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs, stateDir)
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
// Add a secret with multiple versions
|
// Add a secret with multiple versions
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||||
|
|
||||||
// Get versions
|
// Get versions
|
||||||
vaultDir, _ := vlt.GetDirectory()
|
vaultDir, _ := vlt.GetDirectory()
|
||||||
@@ -175,6 +195,7 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -195,22 +216,24 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
assert.Equal(t, []byte("version-1"), value)
|
assert.Equal(t, []byte("version-1"), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs, stateDir)
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("value"), false)
|
addTestSecret(t, vlt, []byte("value"), false)
|
||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
@@ -221,23 +244,24 @@ func TestPromoteNonExistentVersion(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestGetSecretWithVersion(t *testing.T) {
|
func TestGetSecretWithVersion(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs, stateDir)
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
// Add a secret with multiple versions
|
// Add a secret with multiple versions
|
||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecret(t, vlt, "test/secret", []byte("version-2"), true)
|
addTestSecret(t, vlt, []byte("version-2"), true)
|
||||||
|
|
||||||
// Get versions
|
// Get versions
|
||||||
vaultDir, _ := vlt.GetDirectory()
|
vaultDir, _ := vlt.GetDirectory()
|
||||||
@@ -248,6 +272,7 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
|
|
||||||
@@ -258,18 +283,21 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Test getting specific version
|
// Test getting specific version
|
||||||
buf.Reset()
|
buf.Reset()
|
||||||
|
|
||||||
firstVersion := versions[1] // Older version
|
firstVersion := versions[1] // Older version
|
||||||
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
err = cli.GetSecretWithVersion(cmd, "test/secret", firstVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "version-1", buf.String())
|
assert.Equal(t, "version-1", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // reads process environment to determine the state dir
|
||||||
func TestVersionCommandStructure(t *testing.T) {
|
func TestVersionCommandStructure(t *testing.T) {
|
||||||
// Test that version commands are properly structured
|
// Test that version commands are properly structured
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to initialize CLI: %v", err)
|
t.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := VersionCommands(cli)
|
cmd := VersionCommands(cli)
|
||||||
|
|
||||||
assert.Equal(t, "version", cmd.Use)
|
assert.Equal(t, "version", cmd.Use)
|
||||||
@@ -285,13 +313,14 @@ func TestVersionCommandStructure(t *testing.T) {
|
|||||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
||||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs, stateDir)
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
// Create a secret directory without versions (edge case)
|
// Create a secret directory without versions (edge case)
|
||||||
vaultDir := stateDir + "/vaults.d/default"
|
vaultDir := stateDir + "/vaults.d/default"
|
||||||
@@ -301,6 +330,7 @@ func TestListVersionsEmptyOutput(t *testing.T) {
|
|||||||
|
|
||||||
// Create a command for output capture
|
// Create a command for output capture
|
||||||
cmd := newRootCmd()
|
cmd := newRootCmd()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
cmd.SetOut(&buf)
|
cmd.SetOut(&buf)
|
||||||
cmd.SetErr(&buf)
|
cmd.SetErr(&buf)
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
// +build !darwin
|
|
||||||
|
|
||||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
import "fmt"
|
import "errors"
|
||||||
|
|
||||||
var errNotSupported = fmt.Errorf("secure enclave is only supported on macOS") //nolint:gochecknoglobals
|
var errNotSupported = errors.New("secure enclave is only supported on macOS")
|
||||||
|
|
||||||
// CreateKey is not supported on non-darwin platforms.
|
// CreateKey is not supported on non-darwin platforms.
|
||||||
func CreateKey(_ string) ([]byte, string, error) {
|
func CreateKey(_ string) ([]byte, string, error) {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const (
|
|||||||
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
// EnvMnemonic is the environment variable for providing the mnemonic phrase
|
||||||
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
EnvMnemonic = "SB_SECRET_MNEMONIC"
|
||||||
// EnvUnlockPassphrase is the environment variable for providing the unlock passphrase
|
// 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 is the environment variable for providing the GPG key ID
|
||||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,39 +13,61 @@ import (
|
|||||||
"golang.org/x/term"
|
"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
|
// EncryptToRecipient encrypts data to a recipient using age
|
||||||
// The data parameter should be a LockedBuffer for secure memory handling
|
// 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 {
|
if data == nil {
|
||||||
return nil, fmt.Errorf("data buffer is nil")
|
return nil, errNilDataBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
Debug("EncryptToRecipient starting", "data_length", data.Size())
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
Debug("Creating age encryptor")
|
Debug("Creating age encryptor")
|
||||||
|
|
||||||
w, err := age.Encrypt(&buf, recipient)
|
w, err := age.Encrypt(&buf, recipient)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to create encryptor", "error", err)
|
Debug("Failed to create encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to create encryptor: %w", 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")
|
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)
|
Debug("Failed to write data to encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to write data: %w", 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")
|
Debug("Closing encryptor")
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
Debug("Failed to close encryptor", "error", err)
|
Debug("Failed to close encryptor", "error", err)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
return nil, fmt.Errorf("failed to close encryptor: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Closed encryptor successfully")
|
Debug("Closed encryptor successfully")
|
||||||
|
|
||||||
result := buf.Bytes()
|
result := buf.Bytes()
|
||||||
@@ -54,7 +77,9 @@ func EncryptToRecipient(data *memguard.LockedBuffer, recipient age.Recipient) ([
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithIdentity decrypts data with an identity using age
|
// 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)
|
r, err := age.Decrypt(bytes.NewReader(data), identity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create decryptor: %w", err)
|
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
|
// Create a secure buffer for the decrypted data
|
||||||
resultBuffer := memguard.NewBufferFromBytes(result)
|
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 {
|
for i := range result {
|
||||||
result[i] = 0
|
result[i] = 0
|
||||||
}
|
}
|
||||||
@@ -76,17 +102,22 @@ func DecryptWithIdentity(data []byte, identity age.Identity) (*memguard.LockedBu
|
|||||||
return resultBuffer, nil
|
return resultBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
|
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
||||||
// Both data and passphrase parameters should be LockedBuffers for secure memory handling
|
// scrypt-based encryption. Both data and passphrase parameters should
|
||||||
func EncryptWithPassphrase(data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer) ([]byte, error) {
|
// be LockedBuffers for secure memory handling
|
||||||
|
func EncryptWithPassphrase(
|
||||||
|
data *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
|
||||||
|
) ([]byte, error) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, fmt.Errorf("data buffer is nil")
|
return nil, errNilDataBuffer
|
||||||
}
|
|
||||||
if passphrase == nil {
|
|
||||||
return nil, fmt.Errorf("passphrase buffer is nil")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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())
|
recipient, err := age.NewScryptRecipient(passphrase.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
|
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)
|
return EncryptToRecipient(data, recipient)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
|
// DecryptWithPassphrase decrypts data using a passphrase with age's
|
||||||
// The passphrase parameter should be a LockedBuffer for secure memory handling
|
// scrypt-based decryption. The passphrase parameter should be a
|
||||||
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) (*memguard.LockedBuffer, error) {
|
// LockedBuffer for secure memory handling
|
||||||
|
func DecryptWithPassphrase(
|
||||||
|
encryptedData []byte, passphrase *memguard.LockedBuffer,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
if passphrase == nil {
|
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())
|
identity, err := age.NewScryptIdentity(passphrase.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
|
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) {
|
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
|
||||||
// Check if stdin is a terminal
|
// Check if stdin is a terminal
|
||||||
if !term.IsTerminal(syscall.Stdin) {
|
if !term.IsTerminal(syscall.Stdin) {
|
||||||
// Not a terminal - never read passphrases from piped input for security reasons
|
// Not a terminal - never read passphrases from piped input
|
||||||
return nil, fmt.Errorf("cannot read passphrase from non-terminal stdin " +
|
// for security reasons
|
||||||
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
return nil, errStdinNotTerminal
|
||||||
"environment variable or run interactively")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if !term.IsTerminal(syscall.Stderr) {
|
||||||
return nil, fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal " +
|
return nil, errStderrNotTerminal
|
||||||
"(running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE " +
|
|
||||||
"environment variable")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both stdin and stderr are terminals - use secure password reading
|
// Both stdin and stderr are terminals - use secure password reading
|
||||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||||
|
|
||||||
passphrase, err := term.ReadPassword(syscall.Stdin)
|
passphrase, err := term.ReadPassword(syscall.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
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 {
|
if len(passphrase) == 0 {
|
||||||
return nil, fmt.Errorf("passphrase cannot be empty")
|
return nil, errEmptyPassphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer and copy the passphrase
|
// Create a secure buffer and copy the passphrase
|
||||||
|
|||||||
@@ -13,28 +13,33 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
debugEnabled bool //nolint:gochecknoglobals // Package-wide debug state is necessary
|
debugEnabled bool //nolint:gochecknoglobals // package debug state
|
||||||
debugLogger *slog.Logger //nolint:gochecknoglobals // Package-wide logger instance is necessary
|
debugLogger *slog.Logger //nolint:gochecknoglobals // package debug logger
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:gochecknoinits // debug logging must be ready before any package use
|
||||||
func init() {
|
func init() {
|
||||||
InitDebugLogging()
|
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() {
|
func InitDebugLogging() {
|
||||||
godebug := os.Getenv("GODEBUG")
|
godebug := os.Getenv("GODEBUG")
|
||||||
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
debugEnabled = strings.Contains(godebug, "berlin.sneak.pkg.secret")
|
||||||
|
|
||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
// Create a no-op logger that discards all output
|
// Create a no-op logger that discards all output
|
||||||
debugLogger = slog.New(slog.NewTextHandler(io.Discard, nil))
|
debugLogger = slog.New(slog.DiscardHandler)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable stderr buffering for immediate debug output when debugging is enabled
|
// Disable stderr buffering for immediate debug output when
|
||||||
_, _, _ = syscall.Syscall(syscall.SYS_FCNTL, os.Stderr.Fd(), syscall.F_SETFL, syscall.O_SYNC)
|
// 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
|
// Check if STDERR is a TTY
|
||||||
isTTY := term.IsTerminal(syscall.Stderr)
|
isTTY := term.IsTerminal(syscall.Stderr)
|
||||||
@@ -58,14 +63,19 @@ func IsDebugEnabled() bool {
|
|||||||
return debugEnabled
|
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) {
|
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 {
|
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
|
// Debug logs a debug message with optional attributes
|
||||||
@@ -73,14 +83,16 @@ func Debug(msg string, args ...any) {
|
|||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.Debug(msg, args...)
|
debugLogger.Debug(msg, args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DebugF logs a formatted debug message with optional attributes
|
// Debugf logs a formatted debug message with optional attributes
|
||||||
func DebugF(format string, args ...any) {
|
func Debugf(format string, args ...any) {
|
||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.Debug(fmt.Sprintf(format, args...))
|
debugLogger.Debug(fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +101,7 @@ func DebugWith(msg string, attrs ...slog.Attr) {
|
|||||||
if !debugEnabled {
|
if !debugEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLogger.LogAttrs(context.Background(), slog.LevelDebug, msg, attrs...)
|
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 {
|
if record.NumAttrs() > 0 {
|
||||||
output += " \033[33m{"
|
output += " \033[33m{"
|
||||||
first := true
|
first := true
|
||||||
|
|
||||||
record.Attrs(func(attr slog.Attr) bool {
|
record.Attrs(func(attr slog.Attr) bool {
|
||||||
if !first {
|
if !first {
|
||||||
output += ", "
|
output += ", "
|
||||||
}
|
}
|
||||||
|
|
||||||
first = false
|
first = false
|
||||||
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
output += fmt.Sprintf("%s=%#v", attr.Key, attr.Value.Any())
|
||||||
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
output += "}\033[0m"
|
output += "}\033[0m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported debug internals
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -90,9 +91,11 @@ func TestDebugLogging(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // exercises process-global debug logger state
|
||||||
func TestDebugFunctions(t *testing.T) {
|
func TestDebugFunctions(t *testing.T) {
|
||||||
// Enable debug for testing
|
// Enable debug for testing
|
||||||
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
t.Setenv("GODEBUG", "berlin.sneak.pkg.secret")
|
||||||
|
|
||||||
defer InitDebugLogging() // Re-initialize after test
|
defer InitDebugLogging() // Re-initialize after test
|
||||||
|
|
||||||
InitDebugLogging()
|
InitDebugLogging()
|
||||||
@@ -107,8 +110,8 @@ func TestDebugFunctions(t *testing.T) {
|
|||||||
Debug("test with args", "key", "value", "number", 42)
|
Debug("test with args", "key", "value", "number", 42)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DebugF", func(_ *testing.T) {
|
t.Run("Debugf", func(_ *testing.T) {
|
||||||
DebugF("formatted message: %s %d", "test", 123)
|
Debugf("formatted message: %s %d", "test", 123)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DebugWith", func(_ *testing.T) {
|
t.Run("DebugWith", func(_ *testing.T) {
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
"path/filepath"
|
"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.
|
// It returns an error if no usable directory can be determined.
|
||||||
func DetermineStateDir(customConfigDir string) (string, error) {
|
func DetermineStateDir(customConfigDir string) (string, error) {
|
||||||
// Check for environment variable first
|
// 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
|
// Fallback to a reasonable default if we can't determine user config dir
|
||||||
homeDir, homeErr := os.UserHomeDir()
|
homeDir, homeErr := os.UserHomeDir()
|
||||||
if homeErr != nil {
|
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)
|
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
|
return fallbackDir, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package secret
|
package secret_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
|
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
|
// On Darwin, os.UserHomeDir may still succeed via the password
|
||||||
// database, so we also test via an explicit empty-customConfigDir
|
// database, so we also test via an explicit empty-customConfigDir
|
||||||
// path to exercise the fallback branch.
|
// path to exercise the fallback branch.
|
||||||
t.Setenv(EnvStateDir, "")
|
t.Setenv(secret.EnvStateDir, "")
|
||||||
t.Setenv("HOME", "")
|
t.Setenv("HOME", "")
|
||||||
t.Setenv("XDG_CONFIG_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 both lookups fail, we must get an error.
|
||||||
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
// On systems where the OS provides a fallback (e.g. macOS pw db),
|
||||||
// result should still be valid (non-empty, not root-relative).
|
// 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.
|
// Good — the error case is handled.
|
||||||
return
|
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) {
|
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
|
||||||
t.Setenv(EnvStateDir, "/custom/state")
|
t.Setenv(secret.EnvStateDir, "/custom/state")
|
||||||
result, err := DetermineStateDir("")
|
|
||||||
|
result, err := secret.DetermineStateDir("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result != "/custom/state" {
|
if result != "/custom/state" {
|
||||||
t.Errorf("expected /custom/state, got %q", result)
|
t.Errorf("expected /custom/state, got %q", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
|
||||||
t.Setenv(EnvStateDir, "")
|
t.Setenv(secret.EnvStateDir, "")
|
||||||
result, err := DetermineStateDir("/my/config")
|
|
||||||
|
result, err := secret.DetermineStateDir("/my/config")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
expected := "/my/config/" + AppID
|
|
||||||
|
expected := "/my/config/" + secret.AppID
|
||||||
if result != expected {
|
if result != expected {
|
||||||
t.Errorf("expected %q, got %q", expected, result)
|
t.Errorf("expected %q, got %q", expected, result)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
// +build !darwin
|
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -14,6 +13,7 @@ import (
|
|||||||
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
// KeychainUnlockerMetadata is a stub for non-Darwin platforms
|
||||||
type KeychainUnlockerMetadata struct {
|
type KeychainUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
KeychainItemName string `json:"keychainItemName"`
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,7 +24,21 @@ type KeychainUnlocker struct {
|
|||||||
fs afero.Fs
|
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
|
// GetIdentity returns an error on non-Darwin platforms
|
||||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
@@ -48,7 +62,7 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID returns the unlocker ID
|
// GetID returns the unlocker ID
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
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
|
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||||
@@ -61,22 +75,14 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
return errKeychainNotSupported
|
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
|
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||||
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
||||||
return nil, errKeychainNotSupported
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
// 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
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,29 +13,134 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
// testMnemonic is the standard BIP39 test vector mnemonic.
|
||||||
// This test uses real filesystem
|
//
|
||||||
if os.Getenv("CI") == "true" {
|
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||||
t.Log("Running in CI environment with real filesystem")
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
}
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// Create a temporary directory for our tests
|
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
||||||
tempDir, err := os.MkdirTemp("", "secret-passphrase-test-")
|
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 {
|
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
|
// Verify the file exists
|
||||||
fs := afero.NewOsFs()
|
exists, err := afero.Exists(fs, pubKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if public key exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Test data
|
if !exists {
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
t.Errorf("Public key file should exist at %s", pubKeyPath)
|
||||||
testPassphrase := "test-passphrase-123"
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create the directory structure
|
// writeTestPrivateKey encrypts the private key with the passphrase,
|
||||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
// writes it, and verifies it exists.
|
||||||
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
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)
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,86 +159,40 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to generate age identity: %v", err)
|
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()
|
agePrivateKey := ageIdentity.String()
|
||||||
agePublicKey := ageIdentity.Recipient().String()
|
agePublicKey := ageIdentity.Recipient().String()
|
||||||
|
|
||||||
// Test writing public key
|
// Test writing public key
|
||||||
t.Run("WritePublicKey", func(t *testing.T) {
|
t.Run("WritePublicKey", func(t *testing.T) {
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
writeTestPublicKey(t, fs, unlockerDir, agePublicKey)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test encrypting private key with passphrase
|
// Test encrypting private key with passphrase
|
||||||
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
t.Run("EncryptPrivateKey", func(t *testing.T) {
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivateKey))
|
writeTestPrivateKey(t, fs, unlockerDir, agePrivateKey, testPassphrase)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test writing long-term key
|
// Test writing long-term key
|
||||||
t.Run("WriteLongTermKey", func(t *testing.T) {
|
t.Run("WriteLongTermKey", func(t *testing.T) {
|
||||||
// Derive a long-term identity from the test mnemonic
|
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||||
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)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set test environment variable (cleaned up automatically)
|
// Set test environment variable (cleaned up automatically)
|
||||||
@@ -148,18 +207,21 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Verify the identity matches what we expect
|
// Verify the identity matches what we expect
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
|
|
||||||
actualPubKey := identity.Recipient().String()
|
actualPubKey := identity.Recipient().String()
|
||||||
if actualPubKey != expectedPubKey {
|
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
|
// Unset the environment variable to test interactive prompt
|
||||||
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
||||||
|
|
||||||
// Test getting identity from prompt (this would require mocking the prompt)
|
// Test getting identity from prompt (this would require mocking the
|
||||||
// For real integration tests, we'd need to provide a way to mock the passphrase input
|
// prompt). For real integration tests, we'd need a way to mock the
|
||||||
// Here we'll just verify the error is what we expect when no passphrase is available
|
// passphrase input. Here we just verify the error is what we expect
|
||||||
|
// when no passphrase is available.
|
||||||
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
||||||
// This should fail since we're not in an interactive terminal
|
// This should fail since we're not in an interactive terminal
|
||||||
_, err := unlocker.GetIdentity()
|
_, err := unlocker.GetIdentity()
|
||||||
@@ -180,6 +242,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
t.Errorf("Unlocker directory should not exist after removal")
|
t.Errorf("Unlocker directory should not exist after removal")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,37 +19,15 @@ type PassphraseUnlocker struct {
|
|||||||
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPassphrase retrieves the passphrase from memory, environment, or user input
|
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
||||||
// Returns a LockedBuffer for secure memory handling
|
func NewPassphraseUnlocker(
|
||||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||||
// First check if we already have the passphrase
|
) *PassphraseUnlocker {
|
||||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
return &PassphraseUnlocker{
|
||||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
Directory: directory,
|
||||||
// Return a copy of the passphrase buffer
|
Metadata: metadata,
|
||||||
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
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
|
// 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)
|
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
|
||||||
if err != nil {
|
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)
|
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
|
// Decrypt the unlocker private key with passphrase
|
||||||
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
|
||||||
if err != nil {
|
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)
|
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
|
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
||||||
createdAt := p.Metadata.CreatedAt
|
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
|
// 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
|
// For passphrase unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// 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 fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
|
// getPassphrase retrieves the passphrase from memory, environment, or
|
||||||
func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PassphraseUnlocker {
|
// user input. Returns a LockedBuffer for secure memory handling
|
||||||
return &PassphraseUnlocker{
|
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||||
Directory: directory,
|
// First check if we already have the passphrase
|
||||||
Metadata: metadata,
|
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||||
fs: fs,
|
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
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -16,17 +18,28 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"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
|
// Variables to allow overriding in tests
|
||||||
var (
|
var (
|
||||||
// GPGEncryptFunc is the function used for GPG encryption
|
// GPGEncryptFunc is the function used for GPG encryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
//nolint:gochecknoglobals // Required for test mocking
|
//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
|
// GPGDecryptFunc is the function used for GPG decryption
|
||||||
// Can be overridden in tests to provide a non-interactive implementation
|
// Can be overridden in tests to provide a non-interactive implementation
|
||||||
//nolint:gochecknoglobals // Required for test mocking
|
//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
|
// gpgKeyIDRegex validates GPG key IDs
|
||||||
// Allows either:
|
// Allows either:
|
||||||
@@ -45,6 +58,7 @@ var (
|
|||||||
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
||||||
type PGPUnlockerMetadata struct {
|
type PGPUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
// GPG key ID used for encryption
|
// GPG key ID used for encryption
|
||||||
GPGKeyID string `json:"gpgKeyId"`
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
}
|
}
|
||||||
@@ -56,6 +70,17 @@ type PGPUnlocker struct {
|
|||||||
fs afero.Fs
|
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
|
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
||||||
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
DebugWith("Getting PGP unlocker identity",
|
DebugWith("Getting PGP unlocker identity",
|
||||||
@@ -69,7 +94,8 @@ func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
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)
|
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
|
// Step 2: Decrypt the age private key using GPG
|
||||||
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
||||||
if err != nil {
|
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)
|
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
|
// Step 3: Parse the decrypted age private key
|
||||||
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
||||||
|
|
||||||
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
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))
|
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
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
func (p *PGPUnlocker) Remove() error {
|
func (p *PGPUnlocker) Remove() error {
|
||||||
// For PGP unlockers, we just need to remove the directory
|
// For PGP unlockers, we just need to remove the directory
|
||||||
// No external resources (like keychain items) to clean up
|
// 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 fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
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
|
// GetGPGKeyID returns the GPG key ID from metadata
|
||||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
||||||
// Load the metadata
|
// Load the metadata
|
||||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var pgpMetadata PGPUnlockerMetadata
|
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 "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return pgpMetadata.GPGKeyID, nil
|
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) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -189,34 +214,55 @@ func generatePGPUnlockerName() (string, error) {
|
|||||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
// preparePGPUnlockerDir checks GPG availability and creates the
|
||||||
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
|
// 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
|
// Check if GPG is available
|
||||||
if err := checkGPGAvailable(); err != nil {
|
err := checkGPGAvailable()
|
||||||
return nil, err
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
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
|
// Generate the unlocker name based on hostname and date
|
||||||
unlockerName, err := generatePGPUnlockerName()
|
unlockerName, err := generatePGPUnlockerName()
|
||||||
if err != nil {
|
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
|
// Create unlocker directory using the generated name
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
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)
|
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
|
// 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
|
// Step 2: Store age recipient as plaintext
|
||||||
ageRecipient := ageIdentity.Recipient().String()
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
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)
|
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()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 7: Encrypt long-term private key to the new age unlocker
|
// 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 {
|
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
|
// Write encrypted long-term private key
|
||||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
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)
|
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")
|
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)
|
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)
|
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 10: Create and write enhanced metadata with full fingerprint
|
|
||||||
pgpMetadata := PGPUnlockerMetadata{
|
pgpMetadata := PGPUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: "pgp",
|
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)
|
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"),
|
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 nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return &PGPUnlocker{
|
return &pgpMetadata, nil
|
||||||
Directory: unlockerDir,
|
|
||||||
Metadata: pgpMetadata.UnlockerMetadata,
|
|
||||||
fs: fs,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||||
func validateGPGKeyID(keyID string) error {
|
func validateGPGKeyID(keyID string) error {
|
||||||
if keyID == "" {
|
if keyID == "" {
|
||||||
return fmt.Errorf("GPG key ID cannot be empty")
|
return errGPGKeyIDEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
if !gpgKeyIDRegex.MatchString(keyID) {
|
if !gpgKeyIDRegex.MatchString(keyID) {
|
||||||
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
|
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -315,22 +382,24 @@ func validateGPGKeyID(keyID string) error {
|
|||||||
|
|
||||||
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
||||||
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
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)
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use GPG to get the full fingerprint for the key
|
// 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,
|
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
||||||
)
|
)
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the output to extract the fingerprint
|
// Parse the output to extract the fingerprint
|
||||||
lines := strings.Split(string(output), "\n")
|
for line := range strings.SplitSeq(string(output), "\n") {
|
||||||
for _, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "fpr:") {
|
if strings.HasPrefix(line, "fpr:") {
|
||||||
fields := strings.Split(line, ":")
|
fields := strings.Split(line, ":")
|
||||||
if len(fields) >= 10 && fields[9] != "" {
|
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
|
// checkGPGAvailable verifies that GPG is available
|
||||||
func checkGPGAvailable() error {
|
func checkGPGAvailable() error {
|
||||||
cmd := exec.Command("gpg", "--version")
|
cmd := exec.CommandContext(context.Background(), "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)
|
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
|
return nil
|
||||||
@@ -355,13 +428,16 @@ func checkGPGAvailable() error {
|
|||||||
// gpgEncryptDefault is the default implementation of GPG encryption
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
||||||
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
||||||
if data == nil {
|
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)
|
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,
|
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
||||||
)
|
)
|
||||||
cmd.Stdin = strings.NewReader(data.String())
|
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
|
// gpgDecryptDefault is the default implementation of GPG decryption
|
||||||
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
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))
|
cmd.Stdin = strings.NewReader(string(encryptedData))
|
||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -15,6 +16,18 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"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
|
// VaultInterface defines the interface that vault implementations must satisfy
|
||||||
type VaultInterface interface {
|
type VaultInterface interface {
|
||||||
GetDirectory() (string, error)
|
GetDirectory() (string, error)
|
||||||
@@ -22,7 +35,8 @@ type VaultInterface interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
CreatePassphraseUnlocker(
|
||||||
|
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Secret represents a secret in a vault
|
// 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) {
|
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
||||||
DebugWith("Getting secret value",
|
DebugWith("Getting secret value",
|
||||||
slog.String("secret_name", s.Name),
|
slog.String("secret_name", s.Name),
|
||||||
@@ -72,14 +87,17 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
|||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
exists, err := s.Exists()
|
exists, err := s.Exists()
|
||||||
if err != nil {
|
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)
|
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)
|
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
|
// Create version object
|
||||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
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 != "" {
|
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
||||||
Debug("Using mnemonic from environment for direct long-term key derivation", "secret_name", s.Name)
|
return s.getValueViaMnemonic(version, envMnemonic)
|
||||||
|
|
||||||
// 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
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 {
|
if unlocker == nil {
|
||||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
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",
|
ltIdentity, err := s.getLongTermIdentityFromUnlocker(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 {
|
if err != nil {
|
||||||
Debug("Failed to get unlocker identity", "error", err, "secret_name", s.Name, "unlocker_type", unlocker.GetType())
|
return nil, err
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugWith("Successfully obtained vault's long-term key",
|
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
|
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||||
func (s *Secret) LoadMetadata() error {
|
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
|
// For backward compatibility, we'll populate with basic info
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
s.Metadata = Metadata{
|
s.Metadata = Metadata{
|
||||||
@@ -227,9 +164,10 @@ func (s *Secret) GetMetadata() Metadata {
|
|||||||
|
|
||||||
// GetEncryptedData is deprecated - data is now stored in versions
|
// GetEncryptedData is deprecated - data is now stored in versions
|
||||||
func (s *Secret) GetEncryptedData() ([]byte, error) {
|
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
|
// 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
|
// Check if the secret directory exists and has a current symlink
|
||||||
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
exists, err := afero.DirExists(s.vault.GetFilesystem(), s.Directory)
|
||||||
if err != nil {
|
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
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -269,14 +208,134 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
return true, nil
|
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
|
// GetCurrentVault gets the current vault from the file system
|
||||||
// This function is a wrapper around the actual implementation in the vault package
|
// This function is a wrapper around the actual implementation in the vault package
|
||||||
// and exists to break the import cycle.
|
// 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) {
|
func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
||||||
// This is a forward declaration. The actual implementation is provided
|
// This is a forward declaration. The actual implementation is provided
|
||||||
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
// by the vault package when it calls RegisterGetCurrentVaultFunc.
|
||||||
if getCurrentVaultFunc == nil {
|
if getCurrentVaultFunc == nil {
|
||||||
return nil, fmt.Errorf("GetCurrentVault function not registered")
|
return nil, errGetCurrentVaultNotRegistered
|
||||||
}
|
}
|
||||||
|
|
||||||
return getCurrentVaultFunc(fs, stateDir)
|
return getCurrentVaultFunc(fs, stateDir)
|
||||||
@@ -288,8 +347,10 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (VaultInterface, error) {
|
|||||||
//nolint:gochecknoglobals // Required to break import cycle
|
//nolint:gochecknoglobals // Required to break import cycle
|
||||||
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
var getCurrentVaultFunc func(fs afero.Fs, stateDir string) (VaultInterface, error)
|
||||||
|
|
||||||
// RegisterGetCurrentVaultFunc allows the vault package to register its implementation
|
// RegisterGetCurrentVaultFunc allows the vault package to register its
|
||||||
// of GetCurrentVault to break the import cycle
|
// implementation of GetCurrentVault to break the import cycle
|
||||||
func RegisterGetCurrentVaultFunc(fn func(fs afero.Fs, stateDir string) (VaultInterface, error)) {
|
func RegisterGetCurrentVaultFunc(
|
||||||
|
fn func(fs afero.Fs, stateDir string) (VaultInterface, error),
|
||||||
|
) {
|
||||||
getCurrentVaultFunc = fn
|
getCurrentVaultFunc = fn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -14,6 +15,17 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"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
|
// MockVault is a test implementation of the VaultInterface
|
||||||
type MockVault struct {
|
type MockVault struct {
|
||||||
name string
|
name string
|
||||||
@@ -30,14 +42,18 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
// Create secret directory with proper storage name conversion
|
// Create secret directory with proper storage name conversion
|
||||||
storageName := strings.ReplaceAll(name, "/", "%")
|
storageName := strings.ReplaceAll(name, "/", "%")
|
||||||
secretDir := filepath.Join(m.directory, "secrets.d", storageName)
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create version directory with proper path
|
// Create version directory with proper path
|
||||||
versionName := "20240101.001" // Use a fixed version name for testing
|
versionName := "20240101.001" // Use a fixed version name for testing
|
||||||
versionDir := filepath.Join(secretDir, "versions", versionName)
|
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
|
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
|
// Derive long-term key using the vault's derivation index
|
||||||
mnemonic := os.Getenv(EnvMnemonic)
|
mnemonic := os.Getenv(EnvMnemonic)
|
||||||
if mnemonic == "" {
|
if mnemonic == "" {
|
||||||
return fmt.Errorf("SB_SECRET_MNEMONIC not set")
|
return errMnemonicNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
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
|
// 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()
|
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
|
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
|
// Generate version-specific keypair
|
||||||
versionIdentity, err := age.GenerateX25519Identity()
|
versionIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,7 +128,10 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
|
|
||||||
// Write version public key
|
// Write version public key
|
||||||
pubKeyPath := filepath.Join(versionDir, "pub.age")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,60 +143,32 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
|
|
||||||
// Write encrypted value
|
// Write encrypted value
|
||||||
valuePath := filepath.Join(versionDir, "value.age")
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt version private key to long-term public key
|
// Encrypt version private key to long-term public key
|
||||||
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
versionPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
||||||
defer versionPrivKeyBuffer.Destroy()
|
defer versionPrivKeyBuffer.Destroy()
|
||||||
encryptedPrivKey, err := EncryptToRecipient(versionPrivKeyBuffer, ltIdentity.Recipient())
|
|
||||||
|
encryptedPrivKey, err := EncryptToRecipient(
|
||||||
|
versionPrivKeyBuffer, ltIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write encrypted version private key
|
// Write encrypted version private key
|
||||||
privKeyPath := filepath.Join(versionDir, "priv.age")
|
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)
|
return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
|
||||||
currentLink := filepath.Join(secretDir, "current")
|
|
||||||
if err := afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) GetName() string {
|
// setupMockVaultDirs creates the vault directory structure, long-term
|
||||||
return m.name
|
// public key, and current vault pointer for tests.
|
||||||
}
|
func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
|
||||||
|
t.Helper()
|
||||||
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")
|
|
||||||
|
|
||||||
// Create vault directory structure
|
// Create vault directory structure
|
||||||
err := fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms)
|
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
|
// 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 {
|
if err != nil {
|
||||||
t.Fatalf("Failed to generate long-term identity: %v", err)
|
t.Fatalf("Failed to generate long-term identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write long-term public key
|
// Write long-term public key
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
|
||||||
err = afero.WriteFile(
|
err = afero.WriteFile(
|
||||||
fs,
|
fs,
|
||||||
ltPubKeyPath,
|
ltPubKeyPath,
|
||||||
@@ -164,10 +197,56 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
|
|
||||||
// Set current vault
|
// Set current vault
|
||||||
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
currentVaultPath := filepath.Join(baseDir, "currentvault")
|
||||||
|
|
||||||
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
err = afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), FilePerms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to set current vault: %v", err)
|
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
|
// Create vault instance using the mock vault
|
||||||
vault := &MockVault{
|
vault := &MockVault{
|
||||||
@@ -193,30 +272,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that all expected files were created
|
// Verify that all expected files were created
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
verifySecretFiles(t, fs, vaultDir, 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Logf("All expected files created successfully with versioning")
|
t.Logf("All expected files created successfully with versioning")
|
||||||
})
|
})
|
||||||
@@ -245,9 +301,11 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Error checking if secret exists: %v", err)
|
t.Fatalf("Error checking if secret exists: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
t.Fatalf("Secret should exist but Exists() returned false")
|
t.Fatalf("Secret should exist but Exists() returned false")
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("Secret.Exists() works correctly")
|
t.Logf("Secret.Exists() works correctly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -274,6 +332,8 @@ func isValidSecretName(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretNameValidation(t *testing.T) {
|
func TestSecretNameValidation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
valid bool
|
valid bool
|
||||||
@@ -293,6 +353,8 @@ func TestSecretNameValidation(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
result := isValidSecretName(test.name)
|
result := isValidSecretName(test.name)
|
||||||
if result != test.valid {
|
if result != test.valid {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
@@ -311,13 +373,13 @@ func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
|||||||
// instead of the vault's actual derivation index when using environment mnemonic
|
// instead of the vault's actual derivation index when using environment mnemonic
|
||||||
|
|
||||||
// Set up test mnemonic
|
// Set up test mnemonic
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
t.Setenv(EnvMnemonic, testMnemonicValue)
|
||||||
t.Setenv(EnvMnemonic, testMnemonic)
|
|
||||||
|
|
||||||
// Create temporary directory for vaults
|
// Create temporary directory for vaults
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = fs.RemoveAll(tempDir)
|
_ = fs.RemoveAll(tempDir)
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
// +build !darwin
|
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"errors"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/spf13/afero"
|
"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",
|
"secure enclave unlockers are only supported on macOS",
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
|
||||||
type SecureEnclaveUnlockerMetadata struct {
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
SEKeyLabel string `json:"seKeyLabel"`
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
SEKeyHash string `json:"seKeyHash"`
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
}
|
}
|
||||||
@@ -28,6 +31,21 @@ type SecureEnclaveUnlocker struct {
|
|||||||
fs afero.Fs
|
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.
|
// GetIdentity returns an error on non-Darwin platforms.
|
||||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
return nil, errSENotSupported
|
return nil, errSENotSupported
|
||||||
@@ -35,7 +53,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetType returns the unlocker type.
|
// GetType returns the unlocker type.
|
||||||
func (s *SecureEnclaveUnlocker) GetType() string {
|
func (s *SecureEnclaveUnlocker) GetType() string {
|
||||||
return "secure-enclave"
|
return seUnlockerType
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata returns the unlocker metadata.
|
// GetMetadata returns the unlocker metadata.
|
||||||
@@ -50,10 +68,7 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
|
|
||||||
// GetID returns the unlocker ID.
|
// GetID returns the unlocker ID.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
return fmt.Sprintf(
|
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
|
||||||
"%s-secure-enclave",
|
|
||||||
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove returns an error on non-Darwin platforms.
|
// Remove returns an error on non-Darwin platforms.
|
||||||
@@ -61,20 +76,6 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
return errSENotSupported
|
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.
|
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
|
||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
_ afero.Fs,
|
_ afero.Fs,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin
|
||||||
// +build !darwin
|
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test asserting unexported sentinel errors
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,19 +13,21 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker"
|
dir := "/tmp/test-se-unlocker"
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
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)
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
|
||||||
|
|
||||||
// Test GetType returns correct type
|
// 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
|
// Test GetMetadata returns the metadata we passed in
|
||||||
assert.Equal(t, metadata, unlocker.GetMetadata())
|
assert.Equal(t, metadata, unlocker.GetMetadata())
|
||||||
@@ -39,9 +41,11 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,37 +53,43 @@ func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
|||||||
|
|
||||||
identity, err := unlocker.GetIdentity()
|
identity, err := unlocker.GetIdentity()
|
||||||
assert.Nil(t, identity)
|
assert.Nil(t, identity)
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, errSENotSupported)
|
require.ErrorIs(t, err, errSENotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
|
|
||||||
err := unlocker.Remove()
|
err := unlocker.Remove()
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, errSENotSupported)
|
require.ErrorIs(t, err, errSENotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
||||||
assert.Nil(t, unlocker)
|
assert.Nil(t, unlocker)
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.ErrorIs(t, err, errSENotSupported)
|
require.ErrorIs(t, err, errSENotSupported)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -5,148 +6,60 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateGPGKeyID(t *testing.T) {
|
func TestValidateGPGKeyID(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
keyID string
|
keyID string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// Valid cases
|
// 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",
|
"valid fingerprint (40 hex chars)",
|
||||||
keyID: "test@example.com",
|
"ABCDEF1234567890ABCDEF1234567890ABCDEF12", false,
|
||||||
wantErr: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "valid email with dots and hyphens",
|
"valid lowercase hex fingerprint",
|
||||||
keyID: "test.user-name@example-domain.co.uk",
|
"abcdef1234567890abcdef1234567890abcdef12", false,
|
||||||
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 mixed case hex", "AbCdEf1234567890", false},
|
||||||
|
|
||||||
// Invalid cases
|
// 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",
|
"key ID with pipe (command injection)",
|
||||||
keyID: "",
|
"test@example.com | cat /etc/passwd", true,
|
||||||
wantErr: true,
|
|
||||||
},
|
},
|
||||||
|
{"key ID with backticks (command injection)", "test@example.com`whoami`", true},
|
||||||
{
|
{
|
||||||
name: "key ID with spaces",
|
"key ID with dollar sign (command injection)",
|
||||||
keyID: "test user@example.com",
|
"test@example.com$(whoami)", true,
|
||||||
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 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 {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
err := validateGPGKeyID(tt.keyID)
|
err := validateGPGKeyID(tt.keyID)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateGPGKeyID() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -20,12 +21,17 @@ const (
|
|||||||
maxVersionsPerDay = 999
|
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
|
// VersionMetadata contains information about a secret version
|
||||||
type VersionMetadata struct {
|
type VersionMetadata struct {
|
||||||
ID string `json:"id"` // ULID
|
ID string `json:"id"` // ULID
|
||||||
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
CreatedAt *time.Time `json:"createdAt,omitempty"` // When version was created
|
||||||
NotBefore *time.Time `json:"notBefore,omitempty"` // When this version becomes active
|
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
|
// 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")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
// Ensure versions directory exists
|
// 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)
|
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
|
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
|
continue
|
||||||
}
|
}
|
||||||
@@ -115,7 +125,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
|
|||||||
// Generate new version name
|
// Generate new version name
|
||||||
newSerial := maxSerial + 1
|
newSerial := maxSerial + 1
|
||||||
if newSerial > maxVersionsPerDay {
|
if newSerial > maxVersionsPerDay {
|
||||||
return "", fmt.Errorf("exceeded maximum versions per day (999)")
|
return "", errMaxVersionsPerDay
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Sprintf("%s.%03d", today, newSerial), nil
|
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
|
// Save saves the version metadata and value
|
||||||
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return fmt.Errorf("value buffer is nil")
|
return errNilValueBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
DebugWith("Saving secret version",
|
DebugWith("Saving secret version",
|
||||||
@@ -136,14 +146,16 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
fs := sv.vault.GetFilesystem()
|
fs := sv.vault.GetFilesystem()
|
||||||
|
|
||||||
// Create version directory
|
// 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)
|
Debug("Failed to create version directory", "error", err, "dir", sv.Directory)
|
||||||
|
|
||||||
return fmt.Errorf("failed to create version directory: %w", err)
|
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)
|
Debug("Generating version-specific keypair", "version", sv.Version)
|
||||||
|
|
||||||
versionIdentity, err := age.GenerateX25519Identity()
|
versionIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to generate version keypair", "error", err, "version", sv.Version)
|
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)
|
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
versionPublicKey := versionIdentity.Recipient().String()
|
|
||||||
// Store private key in memguard buffer immediately
|
// Store private key in memguard buffer immediately
|
||||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(versionIdentity.String()))
|
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
|
||||||
|
[]byte(versionIdentity.String()))
|
||||||
defer versionPrivateKeyBuffer.Destroy()
|
defer versionPrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Generated version keypair",
|
DebugWith("Generated version keypair",
|
||||||
slog.String("version", sv.Version),
|
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
|
err = sv.writePublicKeyAndValue(fs, versionIdentity, value)
|
||||||
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())
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to encrypt version value", "error", err, "version", sv.Version)
|
return err
|
||||||
|
|
||||||
return fmt.Errorf("failed to encrypt version value: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Store the encrypted value
|
err = sv.writeEncryptedPrivateKey(fs, versionPrivateKeyBuffer)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read long-term public key", "error", err, "path", ltPubKeyPath)
|
return err
|
||||||
|
|
||||||
return fmt.Errorf("failed to read long-term public key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Parsing long-term public key")
|
err = sv.writeEncryptedMetadata(fs, versionIdentity)
|
||||||
ltRecipient, err := age.ParseX25519Recipient(string(ltPubKeyData))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to parse long-term public key", "error", err)
|
return err
|
||||||
|
|
||||||
return fmt.Errorf("failed to parse long-term public key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 6: Encrypt the version's private key to the long-term public key
|
Debug("Successfully saved secret version",
|
||||||
Debug("Encrypting version private key to long-term public key", "version", sv.Version)
|
"version", sv.Version, "secret_name", sv.SecretName)
|
||||||
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)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -270,9 +205,11 @@ func (sv *Version) LoadMetadata(ltIdentity *age.X25519Identity) error {
|
|||||||
|
|
||||||
// Step 1: Read encrypted version private key
|
// Step 1: Read encrypted version private key
|
||||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||||
|
|
||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
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)
|
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
|
// Step 4: Read encrypted metadata
|
||||||
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
encryptedMetadataPath := filepath.Join(sv.Directory, "metadata.age")
|
||||||
|
|
||||||
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
encryptedMetadata, err := afero.ReadFile(fs, encryptedMetadataPath)
|
||||||
if err != nil {
|
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)
|
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
|
// Step 6: Unmarshal metadata
|
||||||
var metadata VersionMetadata
|
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)
|
Debug("Failed to unmarshal version metadata", "error", err, "version", sv.Version)
|
||||||
|
|
||||||
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
return fmt.Errorf("failed to unmarshal version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sv.Metadata = metadata
|
sv.Metadata = metadata
|
||||||
|
|
||||||
Debug("Successfully loaded version metadata", "version", sv.Version)
|
Debug("Successfully loaded version metadata", "version", sv.Version)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the version value
|
// 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",
|
DebugWith("Getting version value",
|
||||||
slog.String("secret_name", sv.SecretName),
|
slog.String("secret_name", sv.SecretName),
|
||||||
slog.String("version", sv.Version),
|
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
|
// Step 1: Read encrypted version private key
|
||||||
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
encryptedPrivKeyPath := filepath.Join(sv.Directory, "priv.age")
|
||||||
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
Debug("Reading encrypted version private key", "path", encryptedPrivKeyPath)
|
||||||
|
|
||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
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
|
// Step 2: Decrypt version private key using long-term key
|
||||||
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
Debug("Decrypting version private key with long-term identity", "version", sv.Version)
|
||||||
|
|
||||||
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
versionPrivKeyBuffer, err := DecryptWithIdentity(encryptedPrivKey, ltIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version private key", "error", err, "version", sv.Version)
|
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)
|
return nil, fmt.Errorf("failed to decrypt version private key: %w", err)
|
||||||
}
|
}
|
||||||
defer versionPrivKeyBuffer.Destroy()
|
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
|
// Step 3: Parse version private key
|
||||||
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
versionIdentity, err := age.ParseX25519Identity(versionPrivKeyBuffer.String())
|
||||||
@@ -375,16 +327,21 @@ func (sv *Version) GetValue(ltIdentity *age.X25519Identity) (*memguard.LockedBuf
|
|||||||
// Step 4: Read encrypted value
|
// Step 4: Read encrypted value
|
||||||
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
encryptedValuePath := filepath.Join(sv.Directory, "value.age")
|
||||||
Debug("Reading encrypted value", "path", encryptedValuePath)
|
Debug("Reading encrypted value", "path", encryptedValuePath)
|
||||||
|
|
||||||
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
encryptedValue, err := afero.ReadFile(fs, encryptedValuePath)
|
||||||
if err != nil {
|
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)
|
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
|
// Step 5: Decrypt value using version key
|
||||||
Debug("Decrypting value with version identity", "version", sv.Version)
|
Debug("Decrypting value with version identity", "version", sv.Version)
|
||||||
|
|
||||||
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
valueBuffer, err := DecryptWithIdentity(encryptedValue, versionIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to decrypt version value", "error", err, "version", sv.Version)
|
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
|
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
|
// ListVersions lists all versions of a secret
|
||||||
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
@@ -409,6 +499,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
return nil, fmt.Errorf("failed to check versions directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
@@ -420,6 +511,7 @@ func ListVersions(fs afero.Fs, secretDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var versions []string
|
var versions []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
versions = append(versions, entry.Name())
|
versions = append(versions, entry.Name())
|
||||||
@@ -456,7 +548,8 @@ func SetCurrentVersion(fs afero.Fs, secretDir string, version string) error {
|
|||||||
_ = fs.Remove(currentPath)
|
_ = fs.Remove(currentPath)
|
||||||
|
|
||||||
// Write just the version name to the file
|
// 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)
|
return fmt.Errorf("failed to create current version file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,22 +32,32 @@
|
|||||||
// - Long-term key required for all operations
|
// - Long-term key required for all operations
|
||||||
// - Concurrent reads handled safely
|
// - Concurrent reads handled safely
|
||||||
|
|
||||||
package secret
|
package secret_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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 {
|
type MockVersionVault struct {
|
||||||
Name string
|
Name string
|
||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
@@ -60,31 +70,37 @@ func (m *MockVersionVault) GetDirectory() (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
func (m *MockVersionVault) AddSecret(_ string, _ *memguard.LockedBuffer, _ bool) error {
|
||||||
return fmt.Errorf("not implemented in mock")
|
return errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) GetName() string {
|
func (m *MockVersionVault) GetName() string {
|
||||||
return m.Name
|
return m.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // implements VaultInterface
|
||||||
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
func (m *MockVersionVault) GetFilesystem() afero.Fs {
|
||||||
return m.fs
|
return m.fs
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
|
//nolint:ireturn // implements VaultInterface
|
||||||
return nil, fmt.Errorf("not implemented in mock")
|
func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||||
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||||
return nil, fmt.Errorf("not implemented in mock")
|
_ *memguard.LockedBuffer,
|
||||||
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateVersionName(t *testing.T) {
|
func TestGenerateVersionName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := "/test/secret"
|
secretDir := testSecretDir
|
||||||
|
|
||||||
// Test first version generation
|
// Test first version generation
|
||||||
version1, err := GenerateVersionName(fs, secretDir)
|
version1, err := secret.GenerateVersionName(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
assert.Regexp(t, `^\d{8}\.001$`, version1)
|
||||||
|
|
||||||
@@ -94,7 +110,7 @@ func TestGenerateVersionName(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Test second version generation on same day
|
// Test second version generation on same day
|
||||||
version2, err := GenerateVersionName(fs, secretDir)
|
version2, err := secret.GenerateVersionName(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
assert.Regexp(t, `^\d{8}\.002$`, version2)
|
||||||
|
|
||||||
@@ -104,8 +120,10 @@ func TestGenerateVersionName(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := "/test/secret"
|
secretDir := testSecretDir
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
// Create 999 versions
|
// Create 999 versions
|
||||||
@@ -117,20 +135,22 @@ func TestGenerateVersionNameMaxSerial(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to create one more - should fail
|
// Try to create one more - should fail
|
||||||
_, err := GenerateVersionName(fs, secretDir)
|
_, err := secret.GenerateVersionName(fs, secretDir)
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewVersion(t *testing.T) {
|
func TestNewVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: "test",
|
Name: testVaultName,
|
||||||
fs: fs,
|
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, "test/secret", sv.SecretName)
|
||||||
assert.Equal(t, "20231215.001", sv.Version)
|
assert.Equal(t, "20231215.001", sv.Version)
|
||||||
@@ -140,11 +160,13 @@ func TestNewVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretVersionSave(t *testing.T) {
|
func TestSecretVersionSave(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: "test",
|
Name: testVaultName,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: "/test",
|
stateDir: testVaultStateDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create vault directory structure and long-term key
|
// Create vault directory structure and long-term key
|
||||||
@@ -155,18 +177,21 @@ func TestSecretVersionSave(t *testing.T) {
|
|||||||
// Generate and store long-term public key
|
// Generate and store long-term public key
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
ltIdentity, err := age.GenerateX25519Identity()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vault.longTermKey = ltIdentity
|
vault.longTermKey = ltIdentity
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create and save a version
|
// 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")
|
testValue := []byte("test-secret-value")
|
||||||
|
|
||||||
testBuffer := memguard.NewBufferFromBytes(testValue)
|
testBuffer := memguard.NewBufferFromBytes(testValue)
|
||||||
defer testBuffer.Destroy()
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
err = sv.Save(testBuffer)
|
err = sv.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -178,11 +203,13 @@ func TestSecretVersionSave(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretVersionLoadMetadata(t *testing.T) {
|
func TestSecretVersionLoadMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: "test",
|
Name: testVaultName,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: "/test",
|
stateDir: testVaultStateDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup vault with long-term key
|
// Setup vault with long-term key
|
||||||
@@ -192,14 +219,16 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
|
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
ltIdentity, err := age.GenerateX25519Identity()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vault.longTermKey = ltIdentity
|
vault.longTermKey = ltIdentity
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create and save a version with custom metadata
|
// 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()
|
now := time.Now()
|
||||||
epochPlusOne := time.Unix(1, 0)
|
epochPlusOne := time.Unix(1, 0)
|
||||||
sv.Metadata.NotBefore = &epochPlusOne
|
sv.Metadata.NotBefore = &epochPlusOne
|
||||||
@@ -207,11 +236,12 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
|
|
||||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer testBuffer.Destroy()
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
err = sv.Save(testBuffer)
|
err = sv.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create new version object and load metadata
|
// 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)
|
err = sv2.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -223,11 +253,13 @@ func TestSecretVersionLoadMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretVersionGetValue(t *testing.T) {
|
func TestSecretVersionGetValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := &MockVersionVault{
|
vault := &MockVersionVault{
|
||||||
Name: "test",
|
Name: testVaultName,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: "/test",
|
stateDir: testVaultStateDir,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup vault with long-term key
|
// Setup vault with long-term key
|
||||||
@@ -237,64 +269,77 @@ func TestSecretVersionGetValue(t *testing.T) {
|
|||||||
|
|
||||||
ltIdentity, err := age.GenerateX25519Identity()
|
ltIdentity, err := age.GenerateX25519Identity()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vault.longTermKey = ltIdentity
|
vault.longTermKey = ltIdentity
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create and save a version
|
// 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")
|
originalValue := []byte("test-secret-value-12345")
|
||||||
expectedValue := make([]byte, len(originalValue))
|
expectedValue := make([]byte, len(originalValue))
|
||||||
copy(expectedValue, originalValue)
|
copy(expectedValue, originalValue)
|
||||||
|
|
||||||
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
originalBuffer := memguard.NewBufferFromBytes(originalValue)
|
||||||
defer originalBuffer.Destroy()
|
defer originalBuffer.Destroy()
|
||||||
|
|
||||||
err = sv.Save(originalBuffer)
|
err = sv.Save(originalBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Retrieve the value
|
// Retrieve the value
|
||||||
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
retrievedBuffer, err := sv.GetValue(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer retrievedBuffer.Destroy()
|
defer retrievedBuffer.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
assert.Equal(t, expectedValue, retrievedBuffer.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListVersions(t *testing.T) {
|
func TestListVersions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := "/test/secret"
|
secretDir := testSecretDir
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
versionsDir := filepath.Join(secretDir, "versions")
|
||||||
|
|
||||||
// No versions directory
|
// No versions directory
|
||||||
versions, err := ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, versions)
|
assert.Empty(t, versions)
|
||||||
|
|
||||||
// Create some 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 {
|
for _, v := range testVersions {
|
||||||
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
err := fs.MkdirAll(filepath.Join(versionsDir, v), 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a file (not directory) that should be ignored
|
// 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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// List versions
|
// List versions
|
||||||
versions, err = ListVersions(fs, secretDir)
|
versions, err = secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Should be sorted in reverse chronological order
|
// 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)
|
assert.Equal(t, expected, versions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCurrentVersion(t *testing.T) {
|
func TestGetCurrentVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := "/test/secret"
|
secretDir := testSecretDir
|
||||||
|
|
||||||
// The current file contains just the version name
|
// The current file contains just the version name
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
@@ -304,39 +349,43 @@ func TestGetCurrentVersion(t *testing.T) {
|
|||||||
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
err = afero.WriteFile(fs, currentPath, []byte("20231216.001"), 0o600)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
version, err := GetCurrentVersion(fs, secretDir)
|
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "20231216.001", version)
|
assert.Equal(t, "20231216.001", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSetCurrentVersion(t *testing.T) {
|
func TestSetCurrentVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
secretDir := "/test/secret"
|
secretDir := testSecretDir
|
||||||
|
|
||||||
err := fs.MkdirAll(secretDir, 0o755)
|
err := fs.MkdirAll(secretDir, 0o755)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set current version
|
// Set current version
|
||||||
err = SetCurrentVersion(fs, secretDir, "20231216.002")
|
err = secret.SetCurrentVersion(fs, secretDir, "20231216.002")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify it was set
|
// Verify it was set
|
||||||
version, err := GetCurrentVersion(fs, secretDir)
|
version, err := secret.GetCurrentVersion(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "20231216.002", version)
|
assert.Equal(t, "20231216.002", version)
|
||||||
|
|
||||||
// Update to different version
|
// Update to different version
|
||||||
err = SetCurrentVersion(fs, secretDir, "20231217.001")
|
err = secret.SetCurrentVersion(fs, secretDir, "20231217.001")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
version, err = GetCurrentVersion(fs, secretDir)
|
version, err = secret.GetCurrentVersion(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "20231217.001", version)
|
assert.Equal(t, "20231217.001", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestVersionMetadataTimestamps(t *testing.T) {
|
func TestVersionMetadataTimestamps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Test that all timestamp fields behave consistently as pointers
|
// Test that all timestamp fields behave consistently as pointers
|
||||||
vm := VersionMetadata{
|
vm := secret.VersionMetadata{
|
||||||
ID: "test-id",
|
ID: "test-id",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,5 +417,6 @@ func TestVersionMetadataTimestamps(t *testing.T) {
|
|||||||
// Helper function
|
// Helper function
|
||||||
func fileExists(fs afero.Fs, path string) bool {
|
func fileExists(fs afero.Fs, path string) bool {
|
||||||
exists, _ := afero.Exists(fs, path)
|
exists, _ := afero.Exists(fs, path)
|
||||||
|
|
||||||
return exists
|
return exists
|
||||||
}
|
}
|
||||||
|
|||||||
65
internal/vault/errors.go
Normal file
65
internal/vault/errors.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package vault
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Sentinel errors returned by vault operations.
|
||||||
|
//
|
||||||
|
// Several of these carry deliberately partial text: the message a caller
|
||||||
|
// composes with fmt.Errorf places the interpolated value where it has
|
||||||
|
// always appeared, and the sentinel supplies only the surrounding fixed
|
||||||
|
// words. This keeps every composed message byte-identical to the dynamic
|
||||||
|
// errors these sentinels replaced. Each such sentinel notes the message it
|
||||||
|
// participates in.
|
||||||
|
var (
|
||||||
|
// ErrMnemonicMismatch indicates the mnemonic-derived public key does
|
||||||
|
// not match the vault's stored public key hash.
|
||||||
|
ErrMnemonicMismatch = errors.New(
|
||||||
|
"derived public key does not match vault: mnemonic may be incorrect",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidVaultName indicates a vault name that does not match the
|
||||||
|
// allowed pattern [a-z0-9.\-_]+. Composed as
|
||||||
|
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
|
||||||
|
ErrInvalidVaultName = errors.New("invalid vault name")
|
||||||
|
|
||||||
|
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
||||||
|
// as "vault <name> does not exist".
|
||||||
|
ErrVaultNotFound = errors.New("does not exist")
|
||||||
|
|
||||||
|
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
||||||
|
ErrNilValueBuffer = errors.New("value buffer is nil")
|
||||||
|
|
||||||
|
// ErrInvalidSecretName indicates a secret name that does not match
|
||||||
|
// the allowed pattern [a-z0-9.\-_/]+. Composed as
|
||||||
|
// "invalid secret name '<name>': must match pattern [a-z0-9.\-_/]+",
|
||||||
|
// or as "invalid secret name: <name>" by GetSecretObject.
|
||||||
|
ErrInvalidSecretName = errors.New("invalid secret name")
|
||||||
|
|
||||||
|
// ErrSecretExists indicates the secret already exists and --force
|
||||||
|
// was not supplied. Composed as
|
||||||
|
// "secret <name> already exists (use --force to overwrite)", or as
|
||||||
|
// "secret '<name>' already exists in vault '<vault>' (use --force to
|
||||||
|
// overwrite)" when copying between vaults.
|
||||||
|
ErrSecretExists = errors.New("already exists")
|
||||||
|
|
||||||
|
// ErrSecretNotFound indicates the named secret does not exist.
|
||||||
|
// Composed as "secret <name> not found".
|
||||||
|
ErrSecretNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// ErrVersionNotFound indicates the requested secret version does not
|
||||||
|
// exist. Composed as
|
||||||
|
// "version <version> not found for secret <name>".
|
||||||
|
ErrVersionNotFound = errors.New("not found for secret")
|
||||||
|
|
||||||
|
// ErrNoVersions indicates the source secret has no versions. Composed
|
||||||
|
// as "source secret '<name>' has no versions".
|
||||||
|
ErrNoVersions = errors.New("has no versions")
|
||||||
|
|
||||||
|
// ErrUnsupportedUnlockerType indicates an unlocker metadata type
|
||||||
|
// that this build does not support.
|
||||||
|
ErrUnsupportedUnlockerType = errors.New("unsupported unlocker type")
|
||||||
|
|
||||||
|
// ErrUnlockerNotFound indicates no unlocker with the given ID exists.
|
||||||
|
// Composed as "unlocker with ID <id> not found".
|
||||||
|
ErrUnlockerNotFound = errors.New("not found")
|
||||||
|
)
|
||||||
@@ -3,8 +3,10 @@ package vault_test
|
|||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
@@ -12,6 +14,33 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// deriveVaultIdentity derives the long-term identity for the given vault
|
||||||
|
// from testMnemonic using the derivation index stored in its metadata.
|
||||||
|
func deriveVaultIdentity(
|
||||||
|
t *testing.T, fs afero.Fs, vlt *vault.Vault,
|
||||||
|
) *age.X25519Identity {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||||
|
vaultMetadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ltIdentity
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||||
// Create a temporary directory for our tests
|
// Create a temporary directory for our tests
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
@@ -19,398 +48,410 @@ func TestVaultWithRealFilesystem(t *testing.T) {
|
|||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test mnemonic
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
// Test currentvault file handling (plain file with relative path)
|
// Test currentvault file handling (plain file with relative path)
|
||||||
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "currentvault-test")
|
testCurrentVaultFileHandling(t, fs, tempDir)
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test vault
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the vault directory
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the currentvault file exists and contains just the vault name
|
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
|
||||||
currentVaultContents, err := os.ReadFile(currentVaultPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read currentvault file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
expectedVaultName := "test-vault"
|
|
||||||
if string(currentVaultContents) != expectedVaultName {
|
|
||||||
t.Errorf("Expected currentvault to contain %q, got %q", expectedVaultName, string(currentVaultContents))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test that ResolveVaultSymlink correctly resolves the path
|
|
||||||
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resolvedPath != vaultDir {
|
|
||||||
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test secret operations with deeply nested paths
|
// Test secret operations with deeply nested paths
|
||||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "deep-path-test")
|
testDeepPathSecrets(t, fs, tempDir)
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load vault metadata to get its derivation index
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive long-term key from mnemonic using the vault's derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the vault
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Create a secret with a deeply nested path
|
|
||||||
deepPath := "api/credentials/production/database/primary"
|
|
||||||
secretValue := []byte("supersecretdbpassword")
|
|
||||||
expectedValue := make([]byte, len(secretValue))
|
|
||||||
copy(expectedValue, secretValue)
|
|
||||||
|
|
||||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secretBuffer.Destroy()
|
|
||||||
|
|
||||||
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to add secret with deep path: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List secrets and verify our deep path secret is there
|
|
||||||
secrets, err := vlt.ListSecrets()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list secrets: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, s := range secrets {
|
|
||||||
if s == deepPath {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Deep path secret not found in listed secrets")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Retrieve the secret and verify its value
|
|
||||||
retrievedValue, err := vlt.GetSecret(deepPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(retrievedValue) != string(expectedValue) {
|
|
||||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
|
||||||
string(expectedValue), string(retrievedValue))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test key caching in GetOrDeriveLongTermKey
|
// Test key caching in GetOrDeriveLongTermKey
|
||||||
t.Run("KeyCaching", func(t *testing.T) {
|
t.Run("KeyCaching", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "key-cache-test")
|
testKeyCaching(t, fs, tempDir)
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a test vault - CreateVault now handles public key when mnemonic is in env
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, "test-vault")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load vault metadata to get its derivation index
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load vault metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive long-term key from mnemonic for verification using the vault's derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the vault is locked initially
|
|
||||||
if !vlt.Locked() {
|
|
||||||
t.Errorf("Vault should be locked initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// First call to GetOrDeriveLongTermKey should derive and cache the key
|
|
||||||
firstKey, err := vlt.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the vault is now unlocked
|
|
||||||
if vlt.Locked() {
|
|
||||||
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second call should return the cached key without re-deriving
|
|
||||||
secondKey, err := vlt.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get cached long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify both keys are the same instance
|
|
||||||
if firstKey != secondKey {
|
|
||||||
t.Errorf("Second key call should return same instance as first call")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the public key matches what we expect
|
|
||||||
expectedPubKey := ltIdentity.Recipient().String()
|
|
||||||
actualPubKey := firstKey.Recipient().String()
|
|
||||||
if actualPubKey != expectedPubKey {
|
|
||||||
t.Errorf("Public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now clear the key and verify it's locked again
|
|
||||||
vlt.ClearLongTermKey()
|
|
||||||
if !vlt.Locked() {
|
|
||||||
t.Errorf("Vault should be locked after clearing key")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the key again and verify it works
|
|
||||||
thirdKey, err := vlt.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to re-derive long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the public key still matches
|
|
||||||
actualPubKey = thirdKey.Recipient().String()
|
|
||||||
if actualPubKey != expectedPubKey {
|
|
||||||
t.Errorf("Re-derived public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test vault name validation
|
// Test vault name validation
|
||||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "name-validation-test")
|
testVaultNameValidation(t, fs, tempDir)
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test valid vault names
|
|
||||||
validNames := []string{
|
|
||||||
"default",
|
|
||||||
"test-vault",
|
|
||||||
"production.vault",
|
|
||||||
"vault_123",
|
|
||||||
"a-very-long-vault-name-with-dashes",
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range validNames {
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, name)
|
|
||||||
if err != nil {
|
|
||||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test invalid vault names
|
|
||||||
invalidNames := []string{
|
|
||||||
"", // Empty
|
|
||||||
"UPPERCASE", // Uppercase not allowed
|
|
||||||
"invalid/name", // Slashes not allowed in vault names
|
|
||||||
"invalid name", // Spaces not allowed
|
|
||||||
"invalid@name", // Special chars not allowed
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, name := range invalidNames {
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, name)
|
|
||||||
if err == nil {
|
|
||||||
t.Errorf("Expected error creating vault with invalid name %q, but got none", name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test multiple vaults and switching between them
|
// Test multiple vaults and switching between them
|
||||||
t.Run("MultipleVaults", func(t *testing.T) {
|
t.Run("MultipleVaults", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
testMultipleVaults(t, fs, tempDir)
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create three vaults
|
|
||||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
|
||||||
for _, name := range vaultNames {
|
|
||||||
_, err := vault.CreateVault(fs, stateDir, name)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// List vaults and verify all three are there
|
|
||||||
vaults, err := vault.ListVaults(fs, stateDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list vaults: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(vaults) != 3 {
|
|
||||||
t.Errorf("Expected 3 vaults, got %d", len(vaults))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test switching between vaults
|
|
||||||
for _, name := range vaultNames {
|
|
||||||
// Select the vault
|
|
||||||
if err := vault.SelectVault(fs, stateDir, name); err != nil {
|
|
||||||
t.Fatalf("Failed to select vault %s: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current vault and verify it's the one we selected
|
|
||||||
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get current vault after selecting %s: %v", name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentVault.GetName() != name {
|
|
||||||
t.Errorf("Expected current vault to be %s, got %s", name, currentVault.GetName())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test adding a secret in one vault and verifying it's not visible in another
|
// Test adding a secret in one vault and verifying it's not visible in
|
||||||
|
// another
|
||||||
t.Run("VaultIsolation", func(t *testing.T) {
|
t.Run("VaultIsolation", func(t *testing.T) {
|
||||||
stateDir := filepath.Join(tempDir, "isolation-test")
|
testVaultIsolation(t, fs, tempDir)
|
||||||
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create state dir: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create two vaults - CreateVault now handles public key when mnemonic is in env
|
|
||||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive long-term key from mnemonic
|
|
||||||
// Note: Both vaults will have different derivation indexes due to GetNextDerivationIndex
|
|
||||||
|
|
||||||
// Load vault1 metadata to get its derivation index
|
|
||||||
vault1Dir, err := vault1.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault1 directory: %v", err)
|
|
||||||
}
|
|
||||||
vault1Metadata, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltIdentity1, err := agehd.DeriveIdentity(testMnemonic, vault1Metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive long-term key for vault1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load vault2 metadata to get its derivation index
|
|
||||||
vault2Dir, err := vault2.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault2 directory: %v", err)
|
|
||||||
}
|
|
||||||
vault2Metadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltIdentity2, err := agehd.DeriveIdentity(testMnemonic, vault2Metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive long-term key for vault2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the vaults with their respective keys
|
|
||||||
vault1.Unlock(ltIdentity1)
|
|
||||||
vault2.Unlock(ltIdentity2)
|
|
||||||
|
|
||||||
// Add a secret to vault1
|
|
||||||
secretName := "test-secret"
|
|
||||||
secretValue := []byte("secret in vault1")
|
|
||||||
|
|
||||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secretBuffer.Destroy()
|
|
||||||
|
|
||||||
if err := vault1.AddSecret(secretName, secretBuffer, false); err != nil {
|
|
||||||
t.Fatalf("Failed to add secret to vault1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the secret exists in vault1
|
|
||||||
vault1Secrets, err := vault1.ListSecrets()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, s := range vault1Secrets {
|
|
||||||
if s == secretName {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Secret not found in vault1")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the secret does NOT exist in vault2
|
|
||||||
vault2Secrets, err := vault2.ListSecrets()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found = false
|
|
||||||
for _, s := range vault2Secrets {
|
|
||||||
if s == secretName {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if found {
|
|
||||||
t.Errorf("Secret from vault1 should not be visible in vault2")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(tempDir, "currentvault-test")
|
||||||
|
|
||||||
|
err := os.MkdirAll(stateDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test vault
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the vault directory
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the currentvault file exists and contains just the vault name
|
||||||
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
|
||||||
|
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read currentvault file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(currentVaultContents) != testVaultName {
|
||||||
|
t.Errorf("Expected currentvault to contain %q, got %q",
|
||||||
|
testVaultName, string(currentVaultContents))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test that ResolveVaultSymlink correctly resolves the path
|
||||||
|
resolvedPath, err := vault.ResolveVaultSymlink(fs, currentVaultPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to resolve currentvault path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolvedPath != vaultDir {
|
||||||
|
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(tempDir, "deep-path-test")
|
||||||
|
|
||||||
|
err := os.MkdirAll(stateDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test vault - CreateVault now handles public key when
|
||||||
|
// mnemonic is in env
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load vault metadata to get its derivation index
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive long-term key from mnemonic using the vault's derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||||
|
vaultMetadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the vault
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create a secret with a deeply nested path
|
||||||
|
deepPath := "api/credentials/production/database/primary"
|
||||||
|
secretValue := []byte("supersecretdbpassword")
|
||||||
|
expectedValue := make([]byte, len(secretValue))
|
||||||
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
err = vlt.AddSecret(deepPath, secretBuffer, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add secret with deep path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List secrets and verify our deep path secret is there
|
||||||
|
secrets, err := vlt.ListSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list secrets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(secrets, deepPath) {
|
||||||
|
t.Errorf("Deep path secret not found in listed secrets")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the secret and verify its value
|
||||||
|
retrievedValue, err := vlt.GetSecret(deepPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
|
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||||
|
string(expectedValue), string(retrievedValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(tempDir, "key-cache-test")
|
||||||
|
|
||||||
|
err := os.MkdirAll(stateDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test vault - CreateVault now handles public key when
|
||||||
|
// mnemonic is in env
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load vault metadata to get its derivation index
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load vault metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive long-term key from mnemonic for verification using the
|
||||||
|
// vault's derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
|
||||||
|
vaultMetadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the vault is locked initially
|
||||||
|
if !vlt.Locked() {
|
||||||
|
t.Errorf("Vault should be locked initially")
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call to GetOrDeriveLongTermKey should derive and cache the key
|
||||||
|
firstKey, err := vlt.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the vault is now unlocked
|
||||||
|
if vlt.Locked() {
|
||||||
|
t.Errorf("Vault should be unlocked after GetOrDeriveLongTermKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call should return the cached key without re-deriving
|
||||||
|
secondKey, err := vlt.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get cached long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both keys are the same instance
|
||||||
|
if firstKey != secondKey {
|
||||||
|
t.Errorf("Second key call should return same instance as first call")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the public key matches what we expect
|
||||||
|
expectedPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
|
actualPubKey := firstKey.Recipient().String()
|
||||||
|
if actualPubKey != expectedPubKey {
|
||||||
|
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||||
|
expectedPubKey, actualPubKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now clear the key and verify it's locked again
|
||||||
|
vlt.ClearLongTermKey()
|
||||||
|
|
||||||
|
if !vlt.Locked() {
|
||||||
|
t.Errorf("Vault should be locked after clearing key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the key again and verify it works
|
||||||
|
thirdKey, err := vlt.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to re-derive long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the public key still matches
|
||||||
|
actualPubKey = thirdKey.Recipient().String()
|
||||||
|
if actualPubKey != expectedPubKey {
|
||||||
|
t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
|
||||||
|
expectedPubKey, actualPubKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(tempDir, "name-validation-test")
|
||||||
|
|
||||||
|
err := os.MkdirAll(stateDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test valid vault names
|
||||||
|
validNames := []string{
|
||||||
|
"default",
|
||||||
|
"test-vault",
|
||||||
|
"production.vault",
|
||||||
|
"vault_123",
|
||||||
|
"a-very-long-vault-name-with-dashes",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range validNames {
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid vault names
|
||||||
|
invalidNames := []string{
|
||||||
|
"", // Empty
|
||||||
|
"UPPERCASE", // Uppercase not allowed
|
||||||
|
"invalid/name", // Slashes not allowed in vault names
|
||||||
|
"invalid name", // Spaces not allowed
|
||||||
|
"invalid@name", // Special chars not allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range invalidNames {
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("Expected error creating vault with invalid name %q, "+
|
||||||
|
"but got none", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(tempDir, "multi-vault-test")
|
||||||
|
|
||||||
|
err := os.MkdirAll(stateDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create three vaults
|
||||||
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||||
|
for _, name := range vaultNames {
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List vaults and verify all three are there
|
||||||
|
vaults, err := vault.ListVaults(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list vaults: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(vaults) != 3 {
|
||||||
|
t.Errorf("Expected 3 vaults, got %d", len(vaults))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test switching between vaults
|
||||||
|
for _, name := range vaultNames {
|
||||||
|
// Select the vault
|
||||||
|
err := vault.SelectVault(fs, stateDir, name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to select vault %s: %v", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current vault and verify it's the one we selected
|
||||||
|
currentVault, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current vault after selecting %s: %v",
|
||||||
|
name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentVault.GetName() != name {
|
||||||
|
t.Errorf("Expected current vault to be %s, got %s",
|
||||||
|
name, currentVault.GetName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := filepath.Join(tempDir, "isolation-test")
|
||||||
|
|
||||||
|
err := os.MkdirAll(stateDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create two vaults - CreateVault now handles public key when mnemonic
|
||||||
|
// is in env
|
||||||
|
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive long-term keys from mnemonic
|
||||||
|
// Note: Both vaults will have different derivation indexes due to
|
||||||
|
// GetNextDerivationIndex
|
||||||
|
ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
|
||||||
|
ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
|
||||||
|
|
||||||
|
// Unlock the vaults with their respective keys
|
||||||
|
vault1.Unlock(ltIdentity1)
|
||||||
|
vault2.Unlock(ltIdentity2)
|
||||||
|
|
||||||
|
// Add a secret to vault1
|
||||||
|
secretValue := []byte("secret in vault1")
|
||||||
|
|
||||||
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
err = vault1.AddSecret(testSecretName, secretBuffer, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add secret to vault1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the secret exists in vault1
|
||||||
|
vault1Secrets, err := vault1.ListSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list secrets in vault1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(vault1Secrets, testSecretName) {
|
||||||
|
t.Errorf("Secret not found in vault1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the secret does NOT exist in vault2
|
||||||
|
vault2Secrets, err := vault2.ListSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list secrets in vault2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(vault2Secrets, testSecretName) {
|
||||||
|
t.Errorf("Secret from vault1 should not be visible in vault2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,14 +19,17 @@
|
|||||||
// - Consistent test mnemonic for reproducible keys
|
// - Consistent test mnemonic for reproducible keys
|
||||||
// - Proper cleanup and isolation between tests
|
// - Proper cleanup and isolation between tests
|
||||||
|
|
||||||
|
//nolint:testpackage // uses white-box test helpers shared with this package
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -35,38 +38,33 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Helper function to add a secret to vault with proper buffer protection
|
// errUnexpectedValue is returned by concurrent readers when a secret value
|
||||||
func addTestSecret(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
// does not match the expected contents.
|
||||||
t.Helper()
|
var errUnexpectedValue = errors.New("unexpected value")
|
||||||
buffer := memguard.NewBufferFromBytes(value)
|
|
||||||
defer buffer.Destroy()
|
|
||||||
err := vault.AddSecret(name, buffer, force)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set mnemonic for testing
|
// Set mnemonic for testing
|
||||||
t.Setenv(secret.EnvMnemonic,
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
"abandon abandon abandon abandon abandon abandon "+
|
|
||||||
"abandon abandon abandon abandon abandon about")
|
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vault, err := CreateVault(fs, stateDir, "test")
|
vault, err := CreateVault(fs, testStateDir, "test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Store long-term public key in vault
|
// Store long-term public key in vault
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Unlock the vault
|
// Unlock the vault
|
||||||
@@ -76,225 +74,289 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
|
|||||||
|
|
||||||
// Step 1: Create initial version
|
// Step 1: Create initial version
|
||||||
t.Run("create_initial_version", func(t *testing.T) {
|
t.Run("create_initial_version", func(t *testing.T) {
|
||||||
addTestSecret(t, vault, secretName, []byte("version-1-data"), false)
|
testCreateInitialVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||||
|
|
||||||
// Verify secret can be retrieved
|
|
||||||
value, err := vault.GetSecret(secretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value)
|
|
||||||
|
|
||||||
// Verify version directory structure
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, versions, 1)
|
|
||||||
|
|
||||||
// Verify current symlink exists
|
|
||||||
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, versions[0], currentVersion)
|
|
||||||
|
|
||||||
// Verify metadata
|
|
||||||
version := secret.NewVersion(vault, secretName, versions[0])
|
|
||||||
err = version.LoadMetadata(ltIdentity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, version.Metadata.CreatedAt)
|
|
||||||
assert.NotNil(t, version.Metadata.NotBefore)
|
|
||||||
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
|
||||||
assert.Nil(t, version.Metadata.NotAfter) // should be nil for current version
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 2: Create second version
|
// Step 2: Create second version
|
||||||
var firstVersionName string
|
|
||||||
t.Run("create_second_version", func(t *testing.T) {
|
t.Run("create_second_version", func(t *testing.T) {
|
||||||
// Small delay to ensure different timestamps
|
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
|
|
||||||
// Get first version name before creating second
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
firstVersionName = versions[0]
|
|
||||||
|
|
||||||
// Create second version
|
|
||||||
addTestSecret(t, vault, secretName, []byte("version-2-data"), true)
|
|
||||||
|
|
||||||
// Verify new value is current
|
|
||||||
value, err := vault.GetSecret(secretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-2-data"), value)
|
|
||||||
|
|
||||||
// Verify we now have two versions
|
|
||||||
versions, err = secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, versions, 2)
|
|
||||||
|
|
||||||
// Verify first version metadata was updated with notAfter
|
|
||||||
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
|
||||||
err = firstVersion.LoadMetadata(ltIdentity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
|
||||||
|
|
||||||
// Verify second version metadata
|
|
||||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
|
||||||
err = secondVersion.LoadMetadata(ltIdentity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
|
||||||
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
|
||||||
|
|
||||||
// NotBefore of second should equal NotAfter of first
|
|
||||||
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(), secondVersion.Metadata.NotBefore.Unix())
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 3: Create third version
|
// Step 3: Create third version
|
||||||
t.Run("create_third_version", func(t *testing.T) {
|
t.Run("create_third_version", func(t *testing.T) {
|
||||||
time.Sleep(10 * time.Millisecond)
|
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
|
||||||
|
|
||||||
addTestSecret(t, vault, secretName, []byte("version-3-data"), true)
|
|
||||||
|
|
||||||
// Verify we now have three versions
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, versions, 3)
|
|
||||||
|
|
||||||
// Current should be version-3
|
|
||||||
value, err := vault.GetSecret(secretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-3-data"), value)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 4: Retrieve specific versions
|
// Step 4: Retrieve specific versions
|
||||||
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
t.Run("retrieve_specific_versions", func(t *testing.T) {
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.Len(t, versions, 3)
|
|
||||||
|
|
||||||
// Get each version by its name
|
|
||||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value1)
|
|
||||||
|
|
||||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-2-data"), value2)
|
|
||||||
|
|
||||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-3-data"), value3)
|
|
||||||
|
|
||||||
// Empty version should return current
|
|
||||||
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 5: Promote old version to current
|
// Step 5: Promote old version to current
|
||||||
t.Run("promote_old_version", func(t *testing.T) {
|
t.Run("promote_old_version", func(t *testing.T) {
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Promote the first version (oldest) to current
|
|
||||||
oldestVersion := versions[2]
|
|
||||||
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify current now returns the old version's value
|
|
||||||
value, err := vault.GetSecret(secretName)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, []byte("version-1-data"), value)
|
|
||||||
|
|
||||||
// Verify the version metadata hasn't changed
|
|
||||||
// (promoting shouldn't modify timestamps)
|
|
||||||
version := secret.NewVersion(vault, secretName, oldestVersion)
|
|
||||||
err = version.LoadMetadata(ltIdentity)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 6: Test version limits
|
// Step 6: Test version limits
|
||||||
t.Run("version_serial_limits", func(t *testing.T) {
|
t.Run("version_serial_limits", func(t *testing.T) {
|
||||||
// Create a new secret for this test
|
testVersionSerialLimits(t, fs, vault, vaultDir)
|
||||||
limitSecretName := "limit/test"
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
|
||||||
|
|
||||||
// Create 998 versions (we already have one from the first AddSecret)
|
|
||||||
addTestSecret(t, vault, limitSecretName, []byte("initial"), false)
|
|
||||||
|
|
||||||
// Get today's date for consistent version names
|
|
||||||
today := time.Now().Format("20060102")
|
|
||||||
|
|
||||||
// Manually create many versions with same date
|
|
||||||
for i := 2; i <= 998; i++ {
|
|
||||||
versionName := fmt.Sprintf("%s.%03d", today, i)
|
|
||||||
versionDir := filepath.Join(secretDir, versionName)
|
|
||||||
err := fs.MkdirAll(versionDir, 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should be able to create one more (999)
|
|
||||||
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName)
|
|
||||||
|
|
||||||
// Create the 999th version directory
|
|
||||||
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Should fail to create 1000th version
|
|
||||||
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Step 7: Test error cases
|
// Step 7: Test error cases
|
||||||
t.Run("error_cases", func(t *testing.T) {
|
t.Run("error_cases", func(t *testing.T) {
|
||||||
// Try to get non-existent version
|
testVersionErrorCases(t, vault, secretName)
|
||||||
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "not found")
|
|
||||||
|
|
||||||
// Try to get version of non-existent secret
|
|
||||||
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
|
||||||
assert.Error(t, err)
|
|
||||||
|
|
||||||
// Try to add secret without force when it exists
|
|
||||||
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
|
||||||
defer failBuffer.Destroy()
|
|
||||||
err = vault.AddSecret(secretName, failBuffer, false)
|
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCreateInitialVersion(
|
||||||
|
t *testing.T, fs afero.Fs, vault *Vault,
|
||||||
|
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
addTestSecretToVault(t, vault, secretName, []byte("version-1-data"), false)
|
||||||
|
|
||||||
|
// Verify secret can be retrieved
|
||||||
|
value, err := vault.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value)
|
||||||
|
|
||||||
|
// Verify version directory structure
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, versions, 1)
|
||||||
|
|
||||||
|
// Verify current symlink exists
|
||||||
|
currentVersion, err := secret.GetCurrentVersion(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, versions[0], currentVersion)
|
||||||
|
|
||||||
|
// Verify metadata
|
||||||
|
version := secret.NewVersion(vault, secretName, versions[0])
|
||||||
|
err = version.LoadMetadata(ltIdentity)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, version.Metadata.CreatedAt)
|
||||||
|
assert.NotNil(t, version.Metadata.NotBefore)
|
||||||
|
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
|
||||||
|
// NotAfter should be nil for current version
|
||||||
|
assert.Nil(t, version.Metadata.NotAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateSecondVersion(
|
||||||
|
t *testing.T, fs afero.Fs, vault *Vault,
|
||||||
|
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Small delay to ensure different timestamps
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
// Get first version name before creating second
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
|
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
firstVersionName := versions[0]
|
||||||
|
|
||||||
|
// Create second version
|
||||||
|
addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
|
||||||
|
|
||||||
|
// Verify new value is current
|
||||||
|
value, err := vault.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value)
|
||||||
|
|
||||||
|
// Verify we now have two versions
|
||||||
|
versions, err = secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, versions, 2)
|
||||||
|
|
||||||
|
// Verify first version metadata was updated with notAfter
|
||||||
|
firstVersion := secret.NewVersion(vault, secretName, firstVersionName)
|
||||||
|
err = firstVersion.LoadMetadata(ltIdentity)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||||
|
|
||||||
|
// Verify second version metadata
|
||||||
|
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
||||||
|
err = secondVersion.LoadMetadata(ltIdentity)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||||
|
assert.Nil(t, secondVersion.Metadata.NotAfter)
|
||||||
|
|
||||||
|
// NotBefore of second should equal NotAfter of first
|
||||||
|
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(),
|
||||||
|
secondVersion.Metadata.NotBefore.Unix())
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCreateThirdVersion(
|
||||||
|
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
|
addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
|
||||||
|
|
||||||
|
// Verify we now have three versions
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, versions, 3)
|
||||||
|
|
||||||
|
// Current should be version-3
|
||||||
|
value, err := vault.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testRetrieveSpecificVersions(
|
||||||
|
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, versions, 3)
|
||||||
|
|
||||||
|
// Get each version by its name
|
||||||
|
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value1)
|
||||||
|
|
||||||
|
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value2)
|
||||||
|
|
||||||
|
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value3)
|
||||||
|
|
||||||
|
// Empty version should return current
|
||||||
|
valueCurrent, err := vault.GetSecretVersion(secretName, "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-3-data"), valueCurrent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPromoteOldVersion(
|
||||||
|
t *testing.T, fs afero.Fs, vault *Vault,
|
||||||
|
ltIdentity *age.X25519Identity, vaultDir, secretName string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Promote the first version (oldest) to current
|
||||||
|
oldestVersion := versions[2]
|
||||||
|
err = secret.SetCurrentVersion(fs, secretDir, oldestVersion)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify current now returns the old version's value
|
||||||
|
value, err := vault.GetSecret(secretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value)
|
||||||
|
|
||||||
|
// Verify the version metadata hasn't changed
|
||||||
|
// (promoting shouldn't modify timestamps)
|
||||||
|
version := secret.NewVersion(vault, secretName, oldestVersion)
|
||||||
|
err = version.LoadMetadata(ltIdentity)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// should still have its old notAfter
|
||||||
|
assert.NotNil(t, version.Metadata.NotAfter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVersionSerialLimits(
|
||||||
|
t *testing.T, fs afero.Fs, vault *Vault, vaultDir string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create a new secret for this test
|
||||||
|
limitSecretName := "limit/test"
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
|
||||||
|
|
||||||
|
// Create 998 versions (we already have one from the first AddSecret)
|
||||||
|
addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
|
||||||
|
|
||||||
|
// Get today's date for consistent version names
|
||||||
|
today := time.Now().Format("20060102")
|
||||||
|
|
||||||
|
// Manually create many versions with same date
|
||||||
|
for i := 2; i <= 998; i++ {
|
||||||
|
versionName := fmt.Sprintf("%s.%03d", today, i)
|
||||||
|
versionDir := filepath.Join(secretDir, versionName)
|
||||||
|
err := fs.MkdirAll(versionDir, 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should be able to create one more (999)
|
||||||
|
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, today+".999", versionName)
|
||||||
|
|
||||||
|
// Create the 999th version directory
|
||||||
|
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should fail to create 1000th version
|
||||||
|
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Try to get non-existent version
|
||||||
|
_, err := vault.GetSecretVersion(secretName, "99991231.999")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "not found")
|
||||||
|
|
||||||
|
// Try to get version of non-existent secret
|
||||||
|
_, err = vault.GetSecretVersion("nonexistent/secret", "")
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Try to add secret without force when it exists
|
||||||
|
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
|
||||||
|
defer failBuffer.Destroy()
|
||||||
|
|
||||||
|
err = vault.AddSecret(secretName, failBuffer, false)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
}
|
||||||
|
|
||||||
// TestVersionConcurrency tests concurrent version operations
|
// TestVersionConcurrency tests concurrent version operations
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVersionConcurrency(t *testing.T) {
|
func TestVersionConcurrency(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
secretName := "concurrent/test"
|
secretName := "concurrent/test"
|
||||||
|
|
||||||
// Create initial version
|
// Create initial version
|
||||||
addTestSecret(t, vault, secretName, []byte("initial"), false)
|
addTestSecretToVault(t, vault, secretName, []byte("initial"), false)
|
||||||
|
|
||||||
// Test concurrent reads
|
// Test concurrent reads
|
||||||
t.Run("concurrent_reads", func(t *testing.T) {
|
t.Run("concurrent_reads", func(t *testing.T) {
|
||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
errors := make(chan error, 10)
|
errCh := make(chan error, 10)
|
||||||
|
|
||||||
for range 10 {
|
for range 10 {
|
||||||
go func() {
|
go func() {
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors <- err
|
errCh <- err
|
||||||
} else if string(value) != "initial" {
|
} else if string(value) != "initial" {
|
||||||
errors <- fmt.Errorf("unexpected value: %s", value)
|
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
done <- true
|
done <- true
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -306,7 +368,7 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
select {
|
select {
|
||||||
case err := <-errors:
|
case err := <-errCh:
|
||||||
t.Fatalf("concurrent read failed: %v", err)
|
t.Fatalf("concurrent read failed: %v", err)
|
||||||
default:
|
default:
|
||||||
// No errors
|
// No errors
|
||||||
@@ -315,12 +377,14 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionCompatibility tests that old secrets without versions still work
|
// TestVersionCompatibility tests that old secrets without versions still work
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVersionCompatibility(t *testing.T) {
|
func TestVersionCompatibility(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -333,9 +397,12 @@ func TestVersionCompatibility(t *testing.T) {
|
|||||||
|
|
||||||
// Create old-style encrypted value directly in secret directory
|
// Create old-style encrypted value directly in secret directory
|
||||||
testValue := []byte("legacy-value")
|
testValue := []byte("legacy-value")
|
||||||
|
|
||||||
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
testValueBuffer := memguard.NewBufferFromBytes(testValue)
|
||||||
defer testValueBuffer.Destroy()
|
defer testValueBuffer.Destroy()
|
||||||
|
|
||||||
ltRecipient := ltIdentity.Recipient()
|
ltRecipient := ltIdentity.Recipient()
|
||||||
|
|
||||||
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
encrypted, err := secret.EncryptToRecipient(testValueBuffer, ltRecipient)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -345,7 +412,7 @@ func TestVersionCompatibility(t *testing.T) {
|
|||||||
|
|
||||||
// Should fail to get with version-aware methods
|
// Should fail to get with version-aware methods
|
||||||
_, err = vault.GetSecret(secretName)
|
_, err = vault.GetSecret(secretName)
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
// List versions should return empty
|
// List versions should return empty
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
|||||||
@@ -15,10 +15,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Register the GetCurrentVault function with the secret package
|
// Register the GetCurrentVault function with the secret package
|
||||||
|
//
|
||||||
|
//nolint:gochecknoinits // registers the vault accessor with the secret package
|
||||||
func init() {
|
func init() {
|
||||||
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
secret.RegisterGetCurrentVaultFunc(
|
||||||
return GetCurrentVault(fs, stateDir)
|
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
||||||
})
|
return GetCurrentVault(fs, stateDir)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
||||||
@@ -27,6 +30,7 @@ func isValidVaultName(name string) bool {
|
|||||||
if name == "" {
|
if name == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_]+$`, name)
|
||||||
|
|
||||||
return matched
|
return matched
|
||||||
@@ -65,9 +69,11 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
|
||||||
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
secret.Debug("Checking current vault symlink", "path", currentVaultPath)
|
||||||
|
|
||||||
_, err := fs.Stat(currentVaultPath)
|
_, err := fs.Stat(currentVaultPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to stat current vault symlink", "error", err, "path", currentVaultPath)
|
secret.Debug("Failed to stat current vault symlink",
|
||||||
|
"error", err, "path", currentVaultPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
return nil, fmt.Errorf("failed to read current vault symlink: %w", err)
|
||||||
}
|
}
|
||||||
@@ -76,6 +82,7 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
|
|
||||||
// Resolve the symlink to get the actual vault directory
|
// Resolve the symlink to get the actual vault directory
|
||||||
secret.Debug("Resolving vault symlink")
|
secret.Debug("Resolving vault symlink")
|
||||||
|
|
||||||
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
targetPath, err := ResolveVaultSymlink(fs, currentVaultPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -88,7 +95,8 @@ func GetCurrentVault(fs afero.Fs, stateDir string) (*Vault, error) {
|
|||||||
vaultName := filepath.Base(targetPath)
|
vaultName := filepath.Base(targetPath)
|
||||||
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
secret.Debug("Extracted vault name", "vault_name", vaultName)
|
||||||
|
|
||||||
secret.Debug("Current vault resolved", "vault_name", vaultName, "target_path", targetPath)
|
secret.Debug("Current vault resolved",
|
||||||
|
"vault_name", vaultName, "target_path", targetPath)
|
||||||
|
|
||||||
// Create and return the vault
|
// Create and return the vault
|
||||||
return NewVault(fs, stateDir, vaultName), nil
|
return NewVault(fs, stateDir, vaultName), nil
|
||||||
@@ -103,6 +111,7 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
@@ -115,6 +124,7 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
|
|
||||||
// Extract vault names
|
// Extract vault names
|
||||||
var vaults []string
|
var vaults []string
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if entry.IsDir() {
|
if entry.IsDir() {
|
||||||
vaults = append(vaults, entry.Name())
|
vaults = append(vaults, entry.Name())
|
||||||
@@ -124,22 +134,26 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
return vaults, nil
|
return vaults, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// processMnemonicForVault handles mnemonic processing for vault creation
|
// processMnemonicForVault handles mnemonic processing for vault creation.
|
||||||
func processMnemonicForVault(fs afero.Fs, stateDir, vaultDir, vaultName string) (
|
// It returns the derivation index, public key hash, and family hash.
|
||||||
derivationIndex uint32, publicKeyHash string, familyHash string, err error) {
|
func processMnemonicForVault(
|
||||||
|
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||||
|
) (uint32, string, string, error) {
|
||||||
// Check if mnemonic is available in environment
|
// Check if mnemonic is available in environment
|
||||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
mnemonic := os.Getenv(secret.EnvMnemonic)
|
||||||
|
|
||||||
if mnemonic == "" {
|
if mnemonic == "" {
|
||||||
secret.Debug("No mnemonic in environment, vault created without long-term key", "vault", vaultName)
|
secret.Debug("No mnemonic in environment, vault created without long-term key",
|
||||||
|
"vault", vaultName)
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
return 0, "", "", nil
|
return 0, "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Mnemonic found in environment, deriving long-term key", "vault", vaultName)
|
secret.Debug("Mnemonic found in environment, deriving long-term key",
|
||||||
|
"vault", vaultName)
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err = GetNextDerivationIndex(fs, stateDir, mnemonic)
|
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
}
|
}
|
||||||
@@ -152,14 +166,18 @@ func processMnemonicForVault(fs afero.Fs, stateDir, vaultDir, vaultName string)
|
|||||||
|
|
||||||
// Write the public key
|
// Write the public key
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
ltPubKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms); err != nil {
|
|
||||||
|
err = afero.WriteFile(fs, ltPubKeyPath, []byte(ltPubKey), secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||||
|
|
||||||
// Compute verification hash from actual derivation index
|
// Compute verification hash from actual derivation index
|
||||||
publicKeyHash = ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
publicKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||||
|
|
||||||
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
// Compute family hash from index 0 (same for all vaults with this mnemonic)
|
||||||
// This is used to identify which vaults belong to the same mnemonic family
|
// This is used to identify which vaults belong to the same mnemonic family
|
||||||
@@ -167,7 +185,8 @@ func processMnemonicForVault(fs afero.Fs, stateDir, vaultDir, vaultName string)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
}
|
}
|
||||||
familyHash = ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
|
||||||
|
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
return derivationIndex, publicKeyHash, familyHash, nil
|
return derivationIndex, publicKeyHash, familyHash, nil
|
||||||
}
|
}
|
||||||
@@ -180,8 +199,12 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
if !isValidVaultName(name) {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
return nil, fmt.Errorf(
|
||||||
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
||||||
|
ErrInvalidVaultName, name,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|
||||||
// Create vault directory structure
|
// Create vault directory structure
|
||||||
@@ -189,24 +212,30 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Create main vault directory
|
// Create main vault directory
|
||||||
if err := fs.MkdirAll(vaultDir, secret.DirPerms); err != nil {
|
err := fs.MkdirAll(vaultDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create secrets directory
|
// Create secrets directory
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
if err := fs.MkdirAll(secretsDir, secret.DirPerms); err != nil {
|
|
||||||
|
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create unlockers directory
|
// Create unlockers directory
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
if err := fs.MkdirAll(unlockersDir, secret.DirPerms); err != nil {
|
|
||||||
|
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process mnemonic if available
|
// Process mnemonic if available
|
||||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(fs, stateDir, vaultDir, name)
|
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
||||||
|
fs, stateDir, vaultDir, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -218,13 +247,17 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
PublicKeyHash: publicKeyHash,
|
PublicKeyHash: publicKeyHash,
|
||||||
MnemonicFamilyHash: familyHash,
|
MnemonicFamilyHash: familyHash,
|
||||||
}
|
}
|
||||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
|
||||||
|
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Select the newly created vault as current
|
// Select the newly created vault as current
|
||||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||||
if err := SelectVault(fs, stateDir, name); err != nil {
|
|
||||||
|
err = SelectVault(fs, stateDir, name)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to select vault: %w", err)
|
return nil, fmt.Errorf("failed to select vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,32 +275,42 @@ func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
|||||||
if !isValidVaultName(name) {
|
if !isValidVaultName(name) {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return fmt.Errorf("invalid vault name '%s': must match pattern [a-z0-9.\\-_]+", name)
|
return fmt.Errorf(
|
||||||
|
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
||||||
|
ErrInvalidVaultName, name,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|
||||||
// Check if vault exists
|
// Check if vault exists
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
|
|
||||||
exists, err := afero.DirExists(fs, vaultDir)
|
exists, err := afero.DirExists(fs, vaultDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if vault exists: %w", err)
|
return fmt.Errorf("failed to check if vault exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("vault %s does not exist", name)
|
return fmt.Errorf("vault %s %w", name, ErrVaultNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create or update the currentvault file with just the vault name
|
// Create or update the currentvault file with just the vault name
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
||||||
|
|
||||||
// Remove existing file if it exists
|
// Remove existing file if it exists
|
||||||
if _, err := fs.Stat(currentVaultPath); err == nil {
|
_, err = fs.Stat(currentVaultPath)
|
||||||
|
if err == nil {
|
||||||
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
secret.Debug("Removing existing currentvault file", "path", currentVaultPath)
|
||||||
|
|
||||||
_ = fs.Remove(currentVaultPath)
|
_ = fs.Remove(currentVaultPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write just the vault name to the file
|
// Write just the vault name to the file
|
||||||
secret.Debug("Writing currentvault file", "vault_name", name)
|
secret.Debug("Writing currentvault file", "vault_name", name)
|
||||||
if err := afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms); err != nil {
|
|
||||||
|
err = afero.WriteFile(fs, currentVaultPath, []byte(name), secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to select vault: %w", err)
|
return fmt.Errorf("failed to select vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ func ComputeDoubleSHA256(data []byte) string {
|
|||||||
|
|
||||||
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
|
// GetNextDerivationIndex finds the next available derivation index for a given mnemonic
|
||||||
// by deriving the public key for index 0 and using its hash to identify related vaults
|
// by deriving the public key for index 0 and using its hash to identify related vaults
|
||||||
func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint32, error) {
|
func GetNextDerivationIndex(
|
||||||
|
fs afero.Fs, stateDir string, mnemonic string,
|
||||||
|
) (uint32, error) {
|
||||||
// First, derive the public key for index 0 to get our identifier
|
// First, derive the public key for index 0 to get our identifier
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
return 0, fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
pubKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||||
@@ -49,6 +52,7 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
return 0, fmt.Errorf("failed to check if vaults directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// No vaults yet, start with index 0
|
// No vaults yet, start with index 0
|
||||||
return 0, nil
|
return 0, nil
|
||||||
@@ -70,6 +74,7 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
|||||||
|
|
||||||
// Try to read vault metadata
|
// Try to read vault metadata
|
||||||
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
metadataPath := filepath.Join(vaultsDir, entry.Name(), "vault-metadata.json")
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Skip vaults without metadata
|
// Skip vaults without metadata
|
||||||
@@ -77,7 +82,9 @@ func GetNextDerivationIndex(fs afero.Fs, stateDir string, mnemonic string) (uint
|
|||||||
}
|
}
|
||||||
|
|
||||||
var metadata Metadata
|
var metadata Metadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
// Skip vaults with invalid metadata
|
// Skip vaults with invalid metadata
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -106,7 +113,8 @@ func SaveVaultMetadata(fs afero.Fs, vaultDir string, metadata *Metadata) error {
|
|||||||
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
return fmt.Errorf("failed to marshal vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write vault metadata: %w", err)
|
return fmt.Errorf("failed to write vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +131,9 @@ func LoadVaultMetadata(fs afero.Fs, vaultDir string) (*Metadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var metadata Metadata
|
var metadata Metadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to unmarshal vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,208 +1,243 @@
|
|||||||
package vault
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
//nolint:paralleltest // subtests share an in-memory filesystem sequentially
|
||||||
func TestVaultMetadata(t *testing.T) {
|
func TestVaultMetadata(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Test mnemonic for consistent testing
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
t.Run("ComputeDoubleSHA256", func(t *testing.T) {
|
||||||
// Test data
|
testComputeDoubleSHA256(t)
|
||||||
data := []byte("test data")
|
|
||||||
hash := ComputeDoubleSHA256(data)
|
|
||||||
|
|
||||||
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
|
||||||
if len(hash) != 64 {
|
|
||||||
t.Errorf("Expected hash length of 64, got %d", len(hash))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify consistency
|
|
||||||
hash2 := ComputeDoubleSHA256(data)
|
|
||||||
if hash != hash2 {
|
|
||||||
t.Errorf("Hash should be consistent for same input")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify different input produces different hash
|
|
||||||
hash3 := ComputeDoubleSHA256([]byte("different data"))
|
|
||||||
if hash == hash3 {
|
|
||||||
t.Errorf("Different input should produce different hash")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
t.Run("GetNextDerivationIndex", func(t *testing.T) {
|
||||||
// Test with no existing vaults
|
testGetNextDerivationIndex(t, fs)
|
||||||
index, err := GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
|
||||||
}
|
|
||||||
if index != 0 {
|
|
||||||
t.Errorf("Expected index 0 for first vault, got %d", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a vault with metadata and matching public key
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1")
|
|
||||||
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive identity for index 0
|
|
||||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive identity: %v", err)
|
|
||||||
}
|
|
||||||
pubKey0 := identity0.Recipient().String()
|
|
||||||
pubKeyHash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
|
||||||
|
|
||||||
// Write public key
|
|
||||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"), []byte(pubKey0), 0o600); err != nil {
|
|
||||||
t.Fatalf("Failed to write public key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata1 := &Metadata{
|
|
||||||
DerivationIndex: 0,
|
|
||||||
PublicKeyHash: pubKeyHash0, // Hash of the actual key (index 0)
|
|
||||||
MnemonicFamilyHash: pubKeyHash0, // Hash of index 0 key (for family identification)
|
|
||||||
}
|
|
||||||
if err := SaveVaultMetadata(fs, vaultDir, metadata1); err != nil {
|
|
||||||
t.Fatalf("Failed to save metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next index for same mnemonic should be 1
|
|
||||||
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
|
||||||
}
|
|
||||||
if index != 1 {
|
|
||||||
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Different mnemonic should start at 0
|
|
||||||
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
|
||||||
index, err = GetNextDerivationIndex(fs, stateDir, differentMnemonic)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
|
||||||
}
|
|
||||||
if index != 0 {
|
|
||||||
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d", index)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add another vault with same mnemonic but higher index
|
|
||||||
vaultDir2 := filepath.Join(stateDir, "vaults.d", "vault2")
|
|
||||||
if err := fs.MkdirAll(vaultDir2, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive identity for index 5
|
|
||||||
identity5, err := agehd.DeriveIdentity(testMnemonic, 5)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive identity: %v", err)
|
|
||||||
}
|
|
||||||
pubKey5 := identity5.Recipient().String()
|
|
||||||
|
|
||||||
// Write public key
|
|
||||||
if err := afero.WriteFile(fs, filepath.Join(vaultDir2, "pub.age"), []byte(pubKey5), 0o600); err != nil {
|
|
||||||
t.Fatalf("Failed to write public key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute the hash for index 5 key
|
|
||||||
pubKeyHash5 := ComputeDoubleSHA256([]byte(pubKey5))
|
|
||||||
|
|
||||||
metadata2 := &Metadata{
|
|
||||||
DerivationIndex: 5,
|
|
||||||
PublicKeyHash: pubKeyHash5, // Hash of the actual key (index 5)
|
|
||||||
MnemonicFamilyHash: pubKeyHash0, // Same family hash since it's from the same mnemonic
|
|
||||||
}
|
|
||||||
if err := SaveVaultMetadata(fs, vaultDir2, metadata2); err != nil {
|
|
||||||
t.Fatalf("Failed to save metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next index should be 1 (not 6) because we look for the first available slot
|
|
||||||
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get derivation index: %v", err)
|
|
||||||
}
|
|
||||||
if index != 1 {
|
|
||||||
t.Errorf("Expected index 1 (first available), got %d", index)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("MetadataPersistence", func(t *testing.T) {
|
t.Run("MetadataPersistence", func(t *testing.T) {
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "test-vault")
|
testMetadataPersistence(t, fs)
|
||||||
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
|
|
||||||
t.Fatalf("Failed to create vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create and save metadata
|
|
||||||
metadata := &Metadata{
|
|
||||||
DerivationIndex: 3,
|
|
||||||
PublicKeyHash: "test-public-key-hash",
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil {
|
|
||||||
t.Fatalf("Failed to save metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load and verify
|
|
||||||
loaded, err := LoadVaultMetadata(fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to load metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if loaded.DerivationIndex != metadata.DerivationIndex {
|
|
||||||
t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.DerivationIndex)
|
|
||||||
}
|
|
||||||
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
|
||||||
t.Errorf("PublicKeyHash mismatch: expected %s, got %s", metadata.PublicKeyHash, loaded.PublicKeyHash)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
|
||||||
// Derive keys with different indices
|
testDifferentKeysForDifferentIndices(t)
|
||||||
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive identity with index 0: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compute public key hashes
|
|
||||||
pubKey0 := identity0.Recipient().String()
|
|
||||||
pubKey1 := identity1.Recipient().String()
|
|
||||||
hash0 := ComputeDoubleSHA256([]byte(pubKey0))
|
|
||||||
|
|
||||||
// Verify different indices produce different public keys
|
|
||||||
if pubKey0 == pubKey1 {
|
|
||||||
t.Errorf("Different derivation indices should produce different public keys")
|
|
||||||
}
|
|
||||||
|
|
||||||
// But the hash of index 0's public key should be the same for the same mnemonic
|
|
||||||
// This is what we use as the identifier
|
|
||||||
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
|
||||||
pubKey0Again := identity0Again.Recipient().String()
|
|
||||||
hash0Again := ComputeDoubleSHA256([]byte(pubKey0Again))
|
|
||||||
|
|
||||||
if hash0 != hash0Again {
|
|
||||||
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testComputeDoubleSHA256(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Test data
|
||||||
|
data := []byte("test data")
|
||||||
|
hash := vault.ComputeDoubleSHA256(data)
|
||||||
|
|
||||||
|
// Verify it's a valid hex string of 64 characters (32 bytes * 2)
|
||||||
|
if len(hash) != 64 {
|
||||||
|
t.Errorf("Expected hash length of 64, got %d", len(hash))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify consistency
|
||||||
|
hash2 := vault.ComputeDoubleSHA256(data)
|
||||||
|
if hash != hash2 {
|
||||||
|
t.Errorf("Hash should be consistent for same input")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify different input produces different hash
|
||||||
|
hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
|
||||||
|
if hash == hash3 {
|
||||||
|
t.Errorf("Different input should produce different hash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createVaultDirWithMetadata creates a vault directory containing a public
|
||||||
|
// key derived from testMnemonic at the given index plus saved metadata, and
|
||||||
|
// returns the derived public key hash. An empty familyHash defaults to the
|
||||||
|
// derived key's own hash.
|
||||||
|
func createVaultDirWithMetadata(
|
||||||
|
t *testing.T, fs afero.Fs, vaultName string,
|
||||||
|
derivationIndex uint32, familyHash string,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaultDir := filepath.Join(testStateDir, "vaults.d", vaultName)
|
||||||
|
|
||||||
|
err := fs.MkdirAll(vaultDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive identity for the requested index
|
||||||
|
identity, err := agehd.DeriveIdentity(testMnemonic, derivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pubKey := identity.Recipient().String()
|
||||||
|
pubKeyHash := vault.ComputeDoubleSHA256([]byte(pubKey))
|
||||||
|
|
||||||
|
// Write public key
|
||||||
|
err = afero.WriteFile(fs, filepath.Join(vaultDir, "pub.age"),
|
||||||
|
[]byte(pubKey), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write public key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if familyHash == "" {
|
||||||
|
familyHash = pubKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &vault.Metadata{
|
||||||
|
DerivationIndex: derivationIndex,
|
||||||
|
PublicKeyHash: pubKeyHash,
|
||||||
|
MnemonicFamilyHash: familyHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pubKeyHash
|
||||||
|
}
|
||||||
|
|
||||||
|
func testGetNextDerivationIndex(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Test with no existing vaults
|
||||||
|
index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if index != 0 {
|
||||||
|
t.Errorf("Expected index 0 for first vault, got %d", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a vault with metadata and matching public key (index 0; the
|
||||||
|
// family hash is the index 0 key hash)
|
||||||
|
pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
|
||||||
|
|
||||||
|
// Next index for same mnemonic should be 1
|
||||||
|
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if index != 1 {
|
||||||
|
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different mnemonic should start at 0
|
||||||
|
//nolint:dupword // BIP39-style test mnemonic
|
||||||
|
differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
|
||||||
|
|
||||||
|
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if index != 0 {
|
||||||
|
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
|
||||||
|
index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add another vault with same mnemonic but higher index (5), sharing
|
||||||
|
// the same family hash since it's from the same mnemonic
|
||||||
|
createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
|
||||||
|
|
||||||
|
// Next index should be 1 (not 6): we look for the first available slot
|
||||||
|
index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get derivation index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if index != 1 {
|
||||||
|
t.Errorf("Expected index 1 (first available), got %d", index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testMetadataPersistence(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
|
||||||
|
|
||||||
|
err := fs.MkdirAll(vaultDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and save metadata
|
||||||
|
metadata := &vault.Metadata{
|
||||||
|
DerivationIndex: 3,
|
||||||
|
PublicKeyHash: "test-public-key-hash",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and verify
|
||||||
|
loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.DerivationIndex != metadata.DerivationIndex {
|
||||||
|
t.Errorf("DerivationIndex mismatch: expected %d, got %d",
|
||||||
|
metadata.DerivationIndex, loaded.DerivationIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
if loaded.PublicKeyHash != metadata.PublicKeyHash {
|
||||||
|
t.Errorf("PublicKeyHash mismatch: expected %s, got %s",
|
||||||
|
metadata.PublicKeyHash, loaded.PublicKeyHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testDifferentKeysForDifferentIndices(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Derive keys with different indices
|
||||||
|
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive identity with index 0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity1, err := agehd.DeriveIdentity(testMnemonic, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive identity with index 1: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute public key hashes
|
||||||
|
pubKey0 := identity0.Recipient().String()
|
||||||
|
pubKey1 := identity1.Recipient().String()
|
||||||
|
hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
|
||||||
|
|
||||||
|
// Verify different indices produce different public keys
|
||||||
|
if pubKey0 == pubKey1 {
|
||||||
|
t.Errorf("Different derivation indices should produce different public keys")
|
||||||
|
}
|
||||||
|
|
||||||
|
// But the hash of index 0's public key should be the same for the same
|
||||||
|
// mnemonic. This is what we use as the identifier
|
||||||
|
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
pubKey0Again := identity0Again.Recipient().String()
|
||||||
|
hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
|
||||||
|
|
||||||
|
if hash0 != hash0Again {
|
||||||
|
t.Errorf("Same mnemonic should produce same public key hash for index 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPublicKeyHashConsistency(t *testing.T) {
|
func TestPublicKeyHashConsistency(t *testing.T) {
|
||||||
// Use the same test mnemonic that the integration test uses
|
t.Parallel()
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Derive identity from index 0 multiple times
|
// Derive identity from index 0 multiple times
|
||||||
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
identity1, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
@@ -223,8 +258,8 @@ func TestPublicKeyHashConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Compute public key hashes
|
// Compute public key hashes
|
||||||
hash1 := ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
hash1 := vault.ComputeDoubleSHA256([]byte(identity1.Recipient().String()))
|
||||||
hash2 := ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
hash2 := vault.ComputeDoubleSHA256([]byte(identity2.Recipient().String()))
|
||||||
|
|
||||||
// Verify hashes are the same
|
// Verify hashes are the same
|
||||||
if hash1 != hash2 {
|
if hash1 != hash2 {
|
||||||
@@ -237,11 +272,15 @@ func TestPublicKeyHashConsistency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSampleHashCalculation(t *testing.T) {
|
func TestSampleHashCalculation(t *testing.T) {
|
||||||
// Test with the exact mnemonic from integration test if available
|
t.Parallel()
|
||||||
// We'll also test with a few different mnemonics to make sure they produce different hashes
|
|
||||||
|
// Test with the exact mnemonic from integration test if available. We
|
||||||
|
// also test with a few different mnemonics to make sure they produce
|
||||||
|
// different hashes
|
||||||
mnemonics := []string{
|
mnemonics := []string{
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
testMnemonic,
|
||||||
"legal winner thank year wave sausage worth useful legal winner thank yellow",
|
"legal winner thank year wave sausage worth useful legal winner thank yellow",
|
||||||
|
//nolint:dupword // BIP39-style test mnemonic
|
||||||
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
"zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,29 +290,29 @@ func TestSampleHashCalculation(t *testing.T) {
|
|||||||
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
|
t.Fatalf("Failed to derive identity for mnemonic %d: %v", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||||
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
|
t.Logf("Mnemonic %d hash (index 0): %s", i, hash)
|
||||||
t.Logf(" Recipient: %s", identity.Recipient().String())
|
t.Logf(" Recipient: %s", identity.Recipient().String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkflowMismatch(t *testing.T) {
|
func TestWorkflowMismatch(t *testing.T) {
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Create a temporary directory for testing
|
// Create a temporary directory for testing
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
_, err := CreateVault(fs, tempDir, "default")
|
|
||||||
|
_, err := vault.CreateVault(fs, tempDir, "default")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load metadata for vault1
|
// Load metadata for vault1
|
||||||
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
||||||
metadata1, err := LoadVaultMetadata(fs, vault1Dir)
|
|
||||||
|
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -281,9 +320,10 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||||
|
|
||||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (like work vault)
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||||
t.Setenv("SB_SECRET_MNEMONIC", "")
|
t.Setenv("SB_SECRET_MNEMONIC", "")
|
||||||
_, err = CreateVault(fs, tempDir, "work")
|
|
||||||
|
_, err = vault.CreateVault(fs, tempDir, "work")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
@@ -294,7 +334,7 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||||
}
|
}
|
||||||
@@ -306,10 +346,12 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
||||||
}
|
}
|
||||||
publicKeyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
|
||||||
|
publicKeyHash := vault.ComputeDoubleSHA256(
|
||||||
|
[]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
// Load existing metadata and update it (same as in VaultImport)
|
// Load existing metadata and update it (same as in VaultImport)
|
||||||
existingMetadata, err := LoadVaultMetadata(fs, vault2Dir)
|
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load existing metadata: %v", err)
|
t.Fatalf("Failed to load existing metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -318,12 +360,13 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
existingMetadata.DerivationIndex = derivationIndex
|
existingMetadata.DerivationIndex = derivationIndex
|
||||||
existingMetadata.PublicKeyHash = publicKeyHash
|
existingMetadata.PublicKeyHash = publicKeyHash
|
||||||
|
|
||||||
if err := SaveVaultMetadata(fs, vault2Dir, existingMetadata); err != nil {
|
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Failed to save vault metadata: %v", err)
|
t.Fatalf("Failed to save vault metadata: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load updated metadata for vault2
|
// Load updated metadata for vault2
|
||||||
metadata2, err := LoadVaultMetadata(fs, vault2Dir)
|
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||||
}
|
}
|
||||||
@@ -337,57 +380,59 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
||||||
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
||||||
} else {
|
} else {
|
||||||
t.Logf("SUCCESS: Both vaults have the same public key hash: %s", metadata1.PublicKeyHash)
|
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
||||||
|
metadata1.PublicKeyHash)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReverseEngineerHash(t *testing.T) {
|
func TestReverseEngineerHash(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// This is the hash that the work vault is getting in the failing test
|
// This is the hash that the work vault is getting in the failing test
|
||||||
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
wrongHash := "e34a2f500e395d8934a90a99ee9311edcfffd68cb701079575e50cbac7bb9417"
|
||||||
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
|
correctHash := "992552b00b3879dfae461fab9a084b47784a032771c7a9accaebdde05ec7a7d1"
|
||||||
|
|
||||||
// Test mnemonic from integration test
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Calculate hash for test mnemonic
|
// Calculate hash for test mnemonic
|
||||||
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
identity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to derive identity: %v", err)
|
t.Fatalf("Failed to derive identity: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
calculatedHash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
calculatedHash := vault.ComputeDoubleSHA256(
|
||||||
|
[]byte(identity.Recipient().String()))
|
||||||
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
t.Logf("Test mnemonic hash: %s", calculatedHash)
|
||||||
|
|
||||||
if calculatedHash == correctHash {
|
if calculatedHash == correctHash {
|
||||||
t.Logf("✓ Test mnemonic produces the correct hash")
|
t.Logf("Test mnemonic produces the correct hash")
|
||||||
} else {
|
} else {
|
||||||
t.Errorf("✗ Test mnemonic does not produce the correct hash")
|
t.Errorf("Test mnemonic does not produce the correct hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
if calculatedHash == wrongHash {
|
if calculatedHash == wrongHash {
|
||||||
t.Logf("✗ Test mnemonic unexpectedly produces the wrong hash")
|
t.Logf("Test mnemonic unexpectedly produces the wrong hash")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Let's try some other possibilities - maybe there's a string normalization issue?
|
// Try some other possibilities: maybe a string normalization issue?
|
||||||
variations := []string{
|
variations := []string{
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
|
testMnemonic,
|
||||||
" abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about ",
|
" " + testMnemonic + " ",
|
||||||
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n",
|
testMnemonic + "\n",
|
||||||
strings.TrimSpace("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"),
|
strings.TrimSpace(testMnemonic),
|
||||||
}
|
}
|
||||||
|
|
||||||
for i, variation := range variations {
|
for i, variation := range variations {
|
||||||
identity, err := agehd.DeriveIdentity(variation, 0)
|
identity, err := agehd.DeriveIdentity(variation, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Variation %d failed: %v", i, err)
|
t.Logf("Variation %d failed: %v", i, err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||||
t.Logf("Variation %d hash: %s", i, hash)
|
t.Logf("Variation %d hash: %s", i, hash)
|
||||||
|
|
||||||
if hash == wrongHash {
|
if hash == wrongHash {
|
||||||
t.Logf("✗ Found variation that produces wrong hash: '%s'", variation)
|
t.Logf("Found variation that produces wrong hash: '%s'", variation)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -401,14 +446,15 @@ func TestReverseEngineerHash(t *testing.T) {
|
|||||||
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
identity, err := agehd.DeriveIdentity(emptyMnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
t.Logf("Empty mnemonic %d failed (expected): %v", i, err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
hash := ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
hash := vault.ComputeDoubleSHA256([]byte(identity.Recipient().String()))
|
||||||
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
t.Logf("Empty mnemonic %d hash: %s", i, hash)
|
||||||
|
|
||||||
if hash == wrongHash {
|
if hash == wrongHash {
|
||||||
t.Logf("✗ Empty mnemonic produces wrong hash!")
|
t.Logf("Empty mnemonic produces wrong hash!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
package vault
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
||||||
// validates the secret name and rejects path traversal attempts.
|
// validates the secret name and rejects path traversal attempts.
|
||||||
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
||||||
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
// Add a legitimate secret so the vault is set up
|
||||||
@@ -42,42 +42,41 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
_, err := vlt.GetSecretVersion(name, "")
|
_, err := vlt.GetSecretVersion(name, "")
|
||||||
assert.Error(t, err, "GetSecretVersion should reject malicious name: %s", name)
|
require.Error(t, err,
|
||||||
assert.Contains(t, err.Error(), "invalid secret name",
|
"GetSecretVersion should reject malicious name: %s", name)
|
||||||
|
require.Contains(t, err.Error(), "invalid secret name",
|
||||||
"error should indicate invalid name for: %s", name)
|
"error should indicate invalid name for: %s", name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls GetSecretVersion)
|
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
||||||
// also rejects path traversal names.
|
// GetSecretVersion) also rejects path traversal names.
|
||||||
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "invalid secret name")
|
require.Contains(t, err.Error(), "invalid secret name")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||||
// also validates names and rejects path traversal attempts.
|
// also validates names and rejects path traversal attempts.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
||||||
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
maliciousNames := []string{
|
maliciousNames := []string{
|
||||||
@@ -89,8 +88,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
_, err := vlt.GetSecretObject(name)
|
_, err := vlt.GetSecretObject(name)
|
||||||
assert.Error(t, err, "GetSecretObject should reject: %s", name)
|
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
||||||
assert.Contains(t, err.Error(), "invalid secret name")
|
require.Contains(t, err.Error(), "invalid secret name")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -21,7 +22,8 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for secret listing", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory for secret listing",
|
||||||
|
"error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -31,12 +33,15 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
// Check if secrets directory exists
|
// Check if secrets directory exists
|
||||||
exists, err := afero.DirExists(v.fs, secretsDir)
|
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check secrets directory", "error", err, "secrets_dir", secretsDir)
|
secret.Debug("Failed to check secrets directory",
|
||||||
|
"error", err, "secrets_dir", secretsDir)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secrets directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Debug("Secrets directory does not exist", "secrets_dir", secretsDir, "vault_name", v.Name)
|
secret.Debug("Secrets directory does not exist",
|
||||||
|
"secrets_dir", secretsDir, "vault_name", v.Name)
|
||||||
|
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
}
|
}
|
||||||
@@ -44,12 +49,14 @@ func (v *Vault) ListSecrets() ([]string, error) {
|
|||||||
// List directories in secrets.d
|
// List directories in secrets.d
|
||||||
files, err := afero.ReadDir(v.fs, secretsDir)
|
files, err := afero.ReadDir(v.fs, secretsDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read secrets directory", "error", err, "secrets_dir", secretsDir)
|
secret.Debug("Failed to read secrets directory",
|
||||||
|
"error", err, "secrets_dir", secretsDir)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
return nil, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var secrets []string
|
var secrets []string
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if file.IsDir() {
|
if file.IsDir() {
|
||||||
// Convert storage name back to secret name
|
// Convert storage name back to secret name
|
||||||
@@ -93,10 +100,8 @@ func isValidSecretName(name string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for path traversal via ".." components
|
// Check for path traversal via ".." components
|
||||||
for _, part := range strings.Split(name, "/") {
|
if slices.Contains(strings.Split(name, "/"), "..") {
|
||||||
if part == ".." {
|
return false
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check the basic pattern
|
// Check the basic pattern
|
||||||
@@ -108,7 +113,7 @@ func isValidSecretName(name string) bool {
|
|||||||
// AddSecret adds a secret to this vault
|
// AddSecret adds a secret to this vault
|
||||||
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return fmt.Errorf("value buffer is nil")
|
return ErrNilValueBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Adding secret to vault",
|
secret.DebugWith("Adding secret to vault",
|
||||||
@@ -122,17 +127,24 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
if !isValidSecretName(name) {
|
if !isValidSecretName(name) {
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||||
|
|
||||||
return fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
return fmt.Errorf(
|
||||||
|
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
|
||||||
|
ErrInvalidSecretName, name,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Secret name validation passed", "secret_name", name)
|
secret.Debug("Secret name validation passed", "secret_name", name)
|
||||||
|
|
||||||
secret.Debug("Getting vault directory")
|
secret.Debug("Getting vault directory")
|
||||||
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for secret addition", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory for secret addition",
|
||||||
|
"error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
secret.Debug("Got vault directory", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Convert slashes to percent signs for storage
|
// Convert slashes to percent signs for storage
|
||||||
@@ -144,112 +156,30 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
slog.String("secret_dir", secretDir),
|
slog.String("secret_dir", secretDir),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Check if secret already exists
|
// Check for an existing secret and prepare its directory
|
||||||
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
|
exists, previousVersion, err := v.prepareSecretDir(name, secretDir, force)
|
||||||
exists, err := afero.DirExists(v.fs, secretDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_dir", secretDir)
|
return err
|
||||||
|
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
|
||||||
}
|
}
|
||||||
secret.Debug("Secret existence check complete", "exists", exists)
|
|
||||||
|
|
||||||
// Handle existing secret case
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
var previousVersion *secret.Version
|
|
||||||
|
|
||||||
if exists {
|
// Create the new version and save the encrypted value
|
||||||
if !force {
|
versionName, err := v.createAndSaveVersion(
|
||||||
secret.Debug("Secret already exists and force not specified", "secret_name", name, "secret_dir", secretDir)
|
name, secretDir, value, previousVersion, &now, exists)
|
||||||
|
|
||||||
return fmt.Errorf("secret %s already exists (use --force to overwrite)", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the current version to update its notAfter timestamp
|
|
||||||
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
|
||||||
if err == nil && currentVersionName != "" {
|
|
||||||
previousVersion = secret.NewVersion(v, name, currentVersionName)
|
|
||||||
// We'll need to load and update its metadata after we unlock the vault
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Create secret directory for new secret
|
|
||||||
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
|
||||||
if err := v.fs.MkdirAll(secretDir, secret.DirPerms); err != nil {
|
|
||||||
secret.Debug("Failed to create secret directory", "error", err, "secret_dir", secretDir)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to create secret directory: %w", err)
|
|
||||||
}
|
|
||||||
secret.Debug("Created secret directory successfully")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate new version name
|
|
||||||
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
return err
|
||||||
|
|
||||||
return fmt.Errorf("failed to generate version name: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
// Update previous version's notAfter timestamp if it exists
|
||||||
|
err = v.updatePreviousVersion(previousVersion, &now)
|
||||||
// Create new version
|
if err != nil {
|
||||||
newVersion := secret.NewVersion(v, name, versionName)
|
return err
|
||||||
|
|
||||||
// Set version timestamps
|
|
||||||
if previousVersion == nil {
|
|
||||||
// First version: notBefore = epoch + 1 second
|
|
||||||
epochPlusOne := time.Unix(1, 0)
|
|
||||||
newVersion.Metadata.NotBefore = &epochPlusOne
|
|
||||||
} else {
|
|
||||||
// New version: notBefore = now
|
|
||||||
newVersion.Metadata.NotBefore = &now
|
|
||||||
|
|
||||||
// We'll update the previous version's notAfter after we save the new version
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the new version - pass the LockedBuffer directly
|
|
||||||
if err := newVersion.Save(value); err != nil {
|
|
||||||
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
|
||||||
|
|
||||||
// Clean up the secret directory if this was a new secret
|
|
||||||
if !exists {
|
|
||||||
secret.Debug("Cleaning up secret directory due to save failure", "secret_dir", secretDir)
|
|
||||||
_ = v.fs.RemoveAll(secretDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to save version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update previous version if it exists
|
|
||||||
if previousVersion != nil {
|
|
||||||
// Get long-term key to decrypt/encrypt metadata
|
|
||||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load previous version metadata
|
|
||||||
if err := previousVersion.LoadMetadata(ltIdentity); err != nil {
|
|
||||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update notAfter timestamp
|
|
||||||
previousVersion.Metadata.NotAfter = &now
|
|
||||||
|
|
||||||
// Re-save the metadata (we need to implement an update method)
|
|
||||||
if err := updateVersionMetadata(v.fs, previousVersion, ltIdentity); err != nil {
|
|
||||||
secret.Debug("Failed to update previous version metadata", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set current symlink to new version
|
// Set current symlink to new version
|
||||||
if err := secret.SetCurrentVersion(v.fs, secretDir, versionName); err != nil {
|
err = secret.SetCurrentVersion(v.fs, secretDir, versionName)
|
||||||
|
if err != nil {
|
||||||
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
secret.Debug("Failed to set current version", "error", err, "version", versionName)
|
||||||
|
|
||||||
return fmt.Errorf("failed to set current version: %w", err)
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
@@ -263,9 +193,12 @@ func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// updateVersionMetadata updates the metadata of an existing version
|
// updateVersionMetadata updates the metadata of an existing version
|
||||||
func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity) error {
|
func updateVersionMetadata(
|
||||||
|
fs afero.Fs, version *secret.Version, ltIdentity *age.X25519Identity,
|
||||||
|
) error {
|
||||||
// Read the version's encrypted private key
|
// Read the version's encrypted private key
|
||||||
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
encryptedPrivKeyPath := filepath.Join(version.Directory, "priv.age")
|
||||||
|
|
||||||
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
encryptedPrivKey, err := afero.ReadFile(fs, encryptedPrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
return fmt.Errorf("failed to read encrypted version private key: %w", err)
|
||||||
@@ -294,14 +227,17 @@ func updateVersionMetadata(fs afero.Fs, version *secret.Version, ltIdentity *age
|
|||||||
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
metadataBuffer := memguard.NewBufferFromBytes(metadataBytes)
|
||||||
defer metadataBuffer.Destroy()
|
defer metadataBuffer.Destroy()
|
||||||
|
|
||||||
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer, versionIdentity.Recipient())
|
encryptedMetadata, err := secret.EncryptToRecipient(metadataBuffer,
|
||||||
|
versionIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
return fmt.Errorf("failed to encrypt version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write encrypted metadata
|
// Write encrypted metadata
|
||||||
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
metadataPath := filepath.Join(version.Directory, "metadata.age")
|
||||||
if err := afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms); err != nil {
|
|
||||||
|
err = afero.WriteFile(fs, metadataPath, encryptedMetadata, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
return fmt.Errorf("failed to write encrypted version metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,7 +254,8 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
|||||||
return v.GetSecretVersion(name, "")
|
return v.GetSecretVersion(name, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecretVersion retrieves a specific version of a secret (empty version means current)
|
// GetSecretVersion retrieves a specific version of a secret (empty version
|
||||||
|
// means current)
|
||||||
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
||||||
secret.DebugWith("Getting secret version from vault",
|
secret.DebugWith("Getting secret version from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
@@ -326,69 +263,17 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Validate secret name to prevent path traversal
|
// Validate the name and resolve the version to fetch
|
||||||
if !isValidSecretName(name) {
|
version, err := v.resolveSecretVersion(name, version)
|
||||||
secret.Debug("Invalid secret name provided", "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get vault directory
|
|
||||||
vaultDir, err := v.GetDirectory()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert slashes to percent signs for storage
|
|
||||||
storageName := strings.ReplaceAll(name, "/", "%")
|
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
|
||||||
|
|
||||||
// Check if secret exists
|
|
||||||
exists, err := afero.DirExists(v.fs, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("secret %s not found", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which version to get
|
|
||||||
if version == "" {
|
|
||||||
// Get current version
|
|
||||||
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
|
||||||
}
|
|
||||||
version = currentVersion
|
|
||||||
secret.Debug("Using current version", "version", version, "secret_name", name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create version object
|
// Create version object
|
||||||
secretVersion := secret.NewVersion(v, name, version)
|
secretVersion := secret.NewVersion(v, name, version)
|
||||||
|
|
||||||
// Check if version exists
|
secret.Debug("Version exists, proceeding with vault unlock and decryption",
|
||||||
versionPath := filepath.Join(secretDir, "versions", version)
|
"version", version, "secret_name", name)
|
||||||
exists, err = afero.DirExists(v.fs, versionPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if version exists: %w", err)
|
|
||||||
}
|
|
||||||
if !exists {
|
|
||||||
secret.Debug("Version not found", "version", version, "secret_name", name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("version %s not found for secret %s", version, name)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.Debug("Version exists, proceeding with vault unlock and decryption", "version", version, "secret_name", name)
|
|
||||||
|
|
||||||
// Unlock the vault (get long-term key in memory)
|
// Unlock the vault (get long-term key in memory)
|
||||||
longTermIdentity, err := v.UnlockVault()
|
longTermIdentity, err := v.UnlockVault()
|
||||||
@@ -406,10 +291,13 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Get the version's value
|
// Get the version's value
|
||||||
secret.Debug("About to call secretVersion.GetValue", "version", version, "secret_name", name)
|
secret.Debug("About to call secretVersion.GetValue",
|
||||||
|
"version", version, "secret_name", name)
|
||||||
|
|
||||||
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
decryptedValue, err := secretVersion.GetValue(longTermIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to decrypt version value", "error", err, "version", version, "secret_name", name)
|
secret.Debug("Failed to decrypt version value",
|
||||||
|
"error", err, "version", version, "secret_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||||
}
|
}
|
||||||
@@ -442,7 +330,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// If vault is already unlocked, return the cached key
|
// If vault is already unlocked, return the cached key
|
||||||
if !v.Locked() {
|
if !v.Locked() {
|
||||||
secret.Debug("Vault already unlocked, returning cached long-term key", "vault_name", v.Name)
|
secret.Debug("Vault already unlocked, returning cached long-term key",
|
||||||
|
"vault_name", v.Name)
|
||||||
|
|
||||||
return v.longTermKey, nil
|
return v.longTermKey, nil
|
||||||
}
|
}
|
||||||
@@ -450,7 +339,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
// Get or derive the long-term key (but don't store it yet)
|
// Get or derive the long-term key (but don't store it yet)
|
||||||
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
longTermIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get or derive long-term key", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get or derive long-term key",
|
||||||
|
"error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -469,7 +359,7 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
|||||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||||
if !isValidSecretName(name) {
|
if !isValidSecretName(name) {
|
||||||
return nil, fmt.Errorf("invalid secret name: %s", name)
|
return nil, fmt.Errorf("%w: %s", ErrInvalidSecretName, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// First check if the secret exists by checking for the metadata file
|
// First check if the secret exists by checking for the metadata file
|
||||||
@@ -487,15 +377,17 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return nil, fmt.Errorf("secret %s not found", name)
|
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a Secret object
|
// Create a Secret object
|
||||||
secretObj := secret.NewSecret(v, name)
|
secretObj := secret.NewSecret(v, name)
|
||||||
|
|
||||||
// Load the metadata from disk
|
// Load the metadata from disk
|
||||||
if err := secretObj.LoadMetadata(); err != nil {
|
err = secretObj.LoadMetadata()
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,7 +418,8 @@ func (v *Vault) CopySecretVersion(
|
|||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Load source metadata
|
// Load source metadata
|
||||||
if err := srcVersion.LoadMetadata(srcIdentity); err != nil {
|
err = srcVersion.LoadMetadata(srcIdentity)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load source metadata: %w", err)
|
return fmt.Errorf("failed to load source metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,7 +430,8 @@ func (v *Vault) CopySecretVersion(
|
|||||||
destVersion.Metadata = srcVersion.Metadata
|
destVersion.Metadata = srcVersion.Metadata
|
||||||
|
|
||||||
// Save the version (encrypts to this vault's LT key)
|
// Save the version (encrypts to this vault's LT key)
|
||||||
if err := destVersion.Save(valueBuffer); err != nil {
|
err = destVersion.Save(valueBuffer)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to save destination version: %w", err)
|
return fmt.Errorf("failed to save destination version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,26 +465,13 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if destination secret already exists
|
// Check if destination secret already exists and clear it if forced
|
||||||
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||||
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||||
|
|
||||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
err = v.prepareCopyDestination(destSecretDir, destSecretName, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check destination: %w", err)
|
return err
|
||||||
}
|
|
||||||
|
|
||||||
if exists && !force {
|
|
||||||
return fmt.Errorf("secret '%s' already exists in vault '%s' (use --force to overwrite)",
|
|
||||||
destSecretName, v.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists && force {
|
|
||||||
// Remove existing secret
|
|
||||||
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
|
||||||
if err := v.fs.RemoveAll(destSecretDir); err != nil {
|
|
||||||
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get source vault's long-term key
|
// Get source vault's long-term key
|
||||||
@@ -615,7 +496,7 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(versions) == 0 {
|
if len(versions) == 0 {
|
||||||
return fmt.Errorf("source secret '%s' has no versions", srcSecretName)
|
return fmt.Errorf("source secret '%s' %w", srcSecretName, ErrNoVersions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current version name
|
// Get current version name
|
||||||
@@ -625,27 +506,16 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create destination secret directory
|
// Create destination secret directory
|
||||||
if err := v.fs.MkdirAll(destSecretDir, secret.DirPerms); err != nil {
|
err = v.fs.MkdirAll(destSecretDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
return fmt.Errorf("failed to create destination secret directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy each version
|
// Copy each version and set the current pointer, rolling back on error
|
||||||
for _, versionName := range versions {
|
err = v.copyVersionsWithRollback(srcVault, srcIdentity,
|
||||||
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||||
if err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName); err != nil {
|
if err != nil {
|
||||||
// Rollback: remove partial copy
|
return err
|
||||||
secret.Debug("Rolling back partial copy due to error", "error", err)
|
|
||||||
_ = v.fs.RemoveAll(destSecretDir)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set current version
|
|
||||||
if err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion); err != nil {
|
|
||||||
_ = v.fs.RemoveAll(destSecretDir)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to set current version: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Successfully copied all secret versions",
|
secret.DebugWith("Successfully copied all secret versions",
|
||||||
@@ -656,3 +526,292 @@ func (v *Vault) CopySecretAllVersions(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prepareSecretDir checks for an existing secret directory and prepares it
|
||||||
|
// for a new version. It returns whether the secret already existed and the
|
||||||
|
// current version to be superseded, if any.
|
||||||
|
func (v *Vault) prepareSecretDir(
|
||||||
|
name, secretDir string, force bool,
|
||||||
|
) (bool, *secret.Version, error) {
|
||||||
|
// Check if secret already exists
|
||||||
|
secret.Debug("Checking if secret already exists", "secret_dir", secretDir)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to check if secret exists",
|
||||||
|
"error", err, "secret_dir", secretDir)
|
||||||
|
|
||||||
|
return false, nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Secret existence check complete", "exists", exists)
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Create secret directory for new secret
|
||||||
|
secret.Debug("Creating secret directory", "secret_dir", secretDir)
|
||||||
|
|
||||||
|
err = v.fs.MkdirAll(secretDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create secret directory",
|
||||||
|
"error", err, "secret_dir", secretDir)
|
||||||
|
|
||||||
|
return false, nil, fmt.Errorf("failed to create secret directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Created secret directory successfully")
|
||||||
|
|
||||||
|
return false, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
secret.Debug("Secret already exists and force not specified",
|
||||||
|
"secret_name", name, "secret_dir", secretDir)
|
||||||
|
|
||||||
|
return true, nil, fmt.Errorf(
|
||||||
|
"secret %s %w (use --force to overwrite)",
|
||||||
|
name, ErrSecretExists,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current version to update its notAfter timestamp
|
||||||
|
var previousVersion *secret.Version
|
||||||
|
|
||||||
|
currentVersionName, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||||
|
if err == nil && currentVersionName != "" {
|
||||||
|
previousVersion = secret.NewVersion(v, name, currentVersionName)
|
||||||
|
// We'll need to load and update its metadata after we unlock the vault
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, previousVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePreviousVersion sets the notAfter timestamp on the version being
|
||||||
|
// superseded. It is a no-op when previousVersion is nil.
|
||||||
|
func (v *Vault) updatePreviousVersion(
|
||||||
|
previousVersion *secret.Version, now *time.Time,
|
||||||
|
) error {
|
||||||
|
if previousVersion == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get long-term key to decrypt/encrypt metadata
|
||||||
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load previous version metadata
|
||||||
|
err = previousVersion.LoadMetadata(ltIdentity)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update notAfter timestamp
|
||||||
|
previousVersion.Metadata.NotAfter = now
|
||||||
|
|
||||||
|
// Re-save the metadata (we need to implement an update method)
|
||||||
|
err = updateVersionMetadata(v.fs, previousVersion, ltIdentity)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to update previous version metadata", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to update previous version metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSecretVersion validates the secret name, verifies the secret and
|
||||||
|
// version exist, and resolves an empty version to the current one.
|
||||||
|
func (v *Vault) resolveSecretVersion(name, version string) (string, error) {
|
||||||
|
// Validate secret name to prevent path traversal
|
||||||
|
if !isValidSecretName(name) {
|
||||||
|
secret.Debug("Invalid secret name provided", "secret_name", name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"%w '%s': must match pattern [a-z0-9.\\-_/]+",
|
||||||
|
ErrInvalidSecretName, name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vault directory
|
||||||
|
vaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get vault directory", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert slashes to percent signs for storage
|
||||||
|
storageName := strings.ReplaceAll(name, "/", "%")
|
||||||
|
secretDir := filepath.Join(vaultDir, "secrets.d", storageName)
|
||||||
|
|
||||||
|
// Check if secret exists
|
||||||
|
exists, err := afero.DirExists(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to check if secret exists", "error", err, "secret_name", name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
secret.Debug("Secret not found in vault", "secret_name", name, "vault_name", v.Name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine which version to get
|
||||||
|
if version == "" {
|
||||||
|
// Get current version
|
||||||
|
currentVersion, err := secret.GetCurrentVersion(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get current version", "error", err, "secret_name", name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to get current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version = currentVersion
|
||||||
|
|
||||||
|
secret.Debug("Using current version", "version", version, "secret_name", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if version exists
|
||||||
|
versionPath := filepath.Join(secretDir, "versions", version)
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(v.fs, versionPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to check if version exists", "error", err, "version", version)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
secret.Debug("Version not found", "version", version, "secret_name", name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf(
|
||||||
|
"version %s %w %s",
|
||||||
|
version, ErrVersionNotFound, name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createAndSaveVersion generates a new version name, sets the version
|
||||||
|
// timestamps, and saves the encrypted value. When saving fails for a newly
|
||||||
|
// created secret, the secret directory is removed again.
|
||||||
|
func (v *Vault) createAndSaveVersion(
|
||||||
|
name, secretDir string, value *memguard.LockedBuffer,
|
||||||
|
previousVersion *secret.Version, now *time.Time, exists bool,
|
||||||
|
) (string, error) {
|
||||||
|
// Generate new version name
|
||||||
|
versionName, err := secret.GenerateVersionName(v.fs, secretDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to generate version name", "error", err, "secret_name", name)
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to generate version name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Generated new version name", "version", versionName, "secret_name", name)
|
||||||
|
|
||||||
|
// Create new version
|
||||||
|
newVersion := secret.NewVersion(v, name, versionName)
|
||||||
|
|
||||||
|
// Set version timestamps
|
||||||
|
if previousVersion == nil {
|
||||||
|
// First version: notBefore = epoch + 1 second
|
||||||
|
epochPlusOne := time.Unix(1, 0)
|
||||||
|
newVersion.Metadata.NotBefore = &epochPlusOne
|
||||||
|
} else {
|
||||||
|
// New version: notBefore = now
|
||||||
|
newVersion.Metadata.NotBefore = now
|
||||||
|
|
||||||
|
// We'll update the previous version's notAfter after we save the
|
||||||
|
// new version
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the new version - pass the LockedBuffer directly
|
||||||
|
err = newVersion.Save(value)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to save new version", "error", err, "version", versionName)
|
||||||
|
|
||||||
|
// Clean up the secret directory if this was a new secret
|
||||||
|
if !exists {
|
||||||
|
secret.Debug("Cleaning up secret directory due to save failure",
|
||||||
|
"secret_dir", secretDir)
|
||||||
|
|
||||||
|
_ = v.fs.RemoveAll(secretDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("failed to save version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return versionName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyVersionsWithRollback copies each version of the source secret into the
|
||||||
|
// destination directory and sets the current version pointer, removing the
|
||||||
|
// partial copy when any step fails.
|
||||||
|
func (v *Vault) copyVersionsWithRollback(
|
||||||
|
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||||
|
srcSecretName, destSecretName, destSecretDir string,
|
||||||
|
versions []string, currentVersion string,
|
||||||
|
) error {
|
||||||
|
// Copy each version
|
||||||
|
for _, versionName := range versions {
|
||||||
|
srcVersion := secret.NewVersion(srcVault, srcSecretName, versionName)
|
||||||
|
|
||||||
|
err := v.CopySecretVersion(srcVersion, srcIdentity, destSecretName, versionName)
|
||||||
|
if err != nil {
|
||||||
|
// Rollback: remove partial copy
|
||||||
|
secret.Debug("Rolling back partial copy due to error", "error", err)
|
||||||
|
|
||||||
|
_ = v.fs.RemoveAll(destSecretDir)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set current version
|
||||||
|
err := secret.SetCurrentVersion(v.fs, destSecretDir, currentVersion)
|
||||||
|
if err != nil {
|
||||||
|
_ = v.fs.RemoveAll(destSecretDir)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to set current version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepareCopyDestination ensures the destination secret directory can be
|
||||||
|
// created, removing an existing secret when force is set.
|
||||||
|
func (v *Vault) prepareCopyDestination(
|
||||||
|
destSecretDir, destSecretName string, force bool,
|
||||||
|
) error {
|
||||||
|
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check destination: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists && !force {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"secret '%s' %w in vault '%s' (use --force to overwrite)",
|
||||||
|
destSecretName, ErrSecretExists, v.Name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists && force {
|
||||||
|
// Remove existing secret
|
||||||
|
secret.Debug("Removing existing destination secret", "path", destSecretDir)
|
||||||
|
|
||||||
|
err = v.fs.RemoveAll(destSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing destination secret: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported isValidSecretName
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
func TestIsValidSecretNameUppercase(t *testing.T) {
|
func TestIsValidSecretNameUppercase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
valid bool
|
valid bool
|
||||||
@@ -33,6 +36,8 @@ func TestIsValidSecretNameUppercase(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
result := isValidSecretName(tt.name)
|
result := isValidSecretName(tt.name)
|
||||||
if result != tt.valid {
|
if result != tt.valid {
|
||||||
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
|
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
//
|
//
|
||||||
// Integration tests for vault-level version operations:
|
// Integration tests for vault-level version operations:
|
||||||
//
|
//
|
||||||
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper version structure
|
// - TestVaultAddSecretCreatesVersion: Tests that AddSecret creates proper
|
||||||
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with force flag
|
// version structure
|
||||||
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current version
|
// - TestVaultAddSecretMultipleVersions: Tests creating multiple versions with
|
||||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter) across versions
|
// force flag
|
||||||
|
// - TestVaultGetSecretVersion: Tests retrieving specific versions and current
|
||||||
|
// version
|
||||||
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||||
|
// across versions
|
||||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||||
//
|
//
|
||||||
@@ -15,6 +19,7 @@
|
|||||||
// - Promotion doesn't modify timestamps
|
// - Promotion doesn't modify timestamps
|
||||||
// - Metadata remains encrypted and intact
|
// - Metadata remains encrypted and intact
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported updateVersionMetadata
|
||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -30,33 +35,61 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testMnemonic is the mnemonic used to derive the vault long-term key.
|
||||||
|
//
|
||||||
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||||
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// envTestMnemonic is the (deliberately different) mnemonic placed in the
|
||||||
|
// environment; the vault is unlocked manually with the derived key in
|
||||||
|
// createTestVaultWithKey.
|
||||||
|
//
|
||||||
|
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
|
||||||
|
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon about"
|
||||||
|
|
||||||
|
// Shared fixtures for white-box tests in this package.
|
||||||
|
const (
|
||||||
|
testStateDir = "/test/state"
|
||||||
|
testSecretPath = "test/secret"
|
||||||
|
)
|
||||||
|
|
||||||
// Helper function to add a secret to vault with proper buffer protection
|
// Helper function to add a secret to vault with proper buffer protection
|
||||||
func addTestSecretToVault(t *testing.T, vault *Vault, name string, value []byte, force bool) {
|
func addTestSecretToVault(
|
||||||
|
t *testing.T, vault *Vault, name string, value []byte, force bool,
|
||||||
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes(value)
|
buffer := memguard.NewBufferFromBytes(value)
|
||||||
defer buffer.Destroy()
|
defer buffer.Destroy()
|
||||||
|
|
||||||
err := vault.AddSecret(name, buffer, force)
|
err := vault.AddSecret(name, buffer, force)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create a vault with long-term key set up
|
// Helper function to create a vault named "test" with its long-term key set
|
||||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName string) *Vault {
|
// up and unlocked
|
||||||
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
// Set mnemonic for testing
|
// Set mnemonic for testing
|
||||||
t.Setenv(secret.EnvMnemonic, "abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
t.Setenv(secret.EnvMnemonic, envTestMnemonic)
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vault, err := CreateVault(fs, stateDir, vaultName)
|
vault, err := CreateVault(fs, testStateDir, "test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, 0)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Store long-term public key in vault
|
// Store long-term public key in vault
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Unlock the vault with the derived key
|
// Unlock the vault with the derived key
|
||||||
@@ -65,20 +98,19 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs, stateDir, vaultName strin
|
|||||||
return vault
|
return vault
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
secretName := "test/secret"
|
|
||||||
secretValue := []byte("initial-value")
|
secretValue := []byte("initial-value")
|
||||||
expectedValue := make([]byte, len(secretValue))
|
expectedValue := make([]byte, len(secretValue))
|
||||||
copy(expectedValue, secretValue)
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
addTestSecretToVault(t, vault, secretName, secretValue, false)
|
addTestSecretToVault(t, vault, testSecretPath, secretValue, false)
|
||||||
|
|
||||||
// Check that version directory was created
|
// Check that version directory was created
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -97,32 +129,31 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|||||||
assert.True(t, exists)
|
assert.True(t, exists)
|
||||||
|
|
||||||
// Get the secret value
|
// Get the secret value
|
||||||
retrievedValue, err := vault.GetSecret(secretName)
|
retrievedValue, err := vault.GetSecret(testSecretPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedValue, retrievedValue)
|
assert.Equal(t, expectedValue, retrievedValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
secretName := "test/secret"
|
|
||||||
|
|
||||||
// Add first version
|
// Add first version
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
||||||
|
|
||||||
// Try to add again without force - should fail
|
// Try to add again without force - should fail
|
||||||
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
failBuffer := memguard.NewBufferFromBytes([]byte("version-2"))
|
||||||
defer failBuffer.Destroy()
|
defer failBuffer.Destroy()
|
||||||
err := vault.AddSecret(secretName, failBuffer, false)
|
|
||||||
assert.Error(t, err)
|
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
||||||
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
|
|
||||||
// Add with force - should create new version
|
// Add with force - should create new version
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||||
|
|
||||||
// Check that we have two versions
|
// Check that we have two versions
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -132,27 +163,25 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|||||||
assert.Len(t, entries, 2)
|
assert.Len(t, entries, 2)
|
||||||
|
|
||||||
// Current value should be version-2
|
// Current value should be version-2
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(testSecretPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2"), value)
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultGetSecretVersion(t *testing.T) {
|
func TestVaultGetSecretVersion(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
secretName := "test/secret"
|
|
||||||
|
|
||||||
// Add multiple versions
|
// Add multiple versions
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-1"), false)
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-1"), false)
|
||||||
|
|
||||||
// Small delay to ensure different version names
|
// Small delay to ensure different version names
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||||
|
|
||||||
// Get versions list
|
// Get versions list
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
@@ -163,58 +192,62 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Get specific version (first one)
|
// Get specific version (first one)
|
||||||
firstVersion := versions[1] // Last in list is first created
|
firstVersion := versions[1] // Last in list is first created
|
||||||
value, err := vault.GetSecretVersion(secretName, firstVersion)
|
value, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-1"), value)
|
assert.Equal(t, []byte("version-1"), value)
|
||||||
|
|
||||||
// Get specific version (second one)
|
// Get specific version (second one)
|
||||||
secondVersion := versions[0] // First in list is most recent
|
secondVersion := versions[0] // First in list is most recent
|
||||||
value, err = vault.GetSecretVersion(secretName, secondVersion)
|
value, err = vault.GetSecretVersion(testSecretPath, secondVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2"), value)
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
|
|
||||||
// Get current (empty version)
|
// Get current (empty version)
|
||||||
value, err = vault.GetSecretVersion(secretName, "")
|
value, err = vault.GetSecretVersion(testSecretPath, "")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2"), value)
|
assert.Equal(t, []byte("version-2"), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultVersionTimestamps(t *testing.T) {
|
func TestVaultVersionTimestamps(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
// Get long-term key
|
// Get long-term key
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
secretName := "test/secret"
|
|
||||||
|
|
||||||
// Add first version
|
// Add first version
|
||||||
beforeFirst := time.Now()
|
beforeFirst := time.Now()
|
||||||
|
|
||||||
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
v1Buffer := memguard.NewBufferFromBytes([]byte("version-1"))
|
||||||
defer v1Buffer.Destroy()
|
defer v1Buffer.Destroy()
|
||||||
err = vault.AddSecret(secretName, v1Buffer, false)
|
|
||||||
|
err = vault.AddSecret(testSecretPath, v1Buffer, false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
afterFirst := time.Now()
|
afterFirst := time.Now()
|
||||||
|
|
||||||
// Get first version metadata
|
// Get first version metadata
|
||||||
vaultDir, _ := vault.GetDirectory()
|
vaultDir, _ := vault.GetDirectory()
|
||||||
secretDir := vaultDir + "/secrets.d/test%secret"
|
secretDir := vaultDir + "/secrets.d/test%secret"
|
||||||
|
|
||||||
versions, err := secret.ListVersions(fs, secretDir)
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, versions, 1)
|
require.Len(t, versions, 1)
|
||||||
|
|
||||||
firstVersion := secret.NewVersion(vault, secretName, versions[0])
|
firstVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||||
err = firstVersion.LoadMetadata(ltIdentity)
|
err = firstVersion.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Check first version timestamps
|
// Check first version timestamps
|
||||||
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
assert.NotNil(t, firstVersion.Metadata.CreatedAt)
|
||||||
assert.True(t, firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
assert.True(t,
|
||||||
assert.True(t, firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
firstVersion.Metadata.CreatedAt.After(beforeFirst.Add(-time.Second)))
|
||||||
|
assert.True(t,
|
||||||
|
firstVersion.Metadata.CreatedAt.Before(afterFirst.Add(time.Second)))
|
||||||
|
|
||||||
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
assert.NotNil(t, firstVersion.Metadata.NotBefore)
|
||||||
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
assert.Equal(t, int64(1), firstVersion.Metadata.NotBefore.Unix()) // Epoch + 1
|
||||||
@@ -222,8 +255,11 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
|
|
||||||
// Add second version
|
// Add second version
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
|
||||||
beforeSecond := time.Now()
|
beforeSecond := time.Now()
|
||||||
addTestSecretToVault(t, vault, secretName, []byte("version-2"), true)
|
|
||||||
|
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||||
|
|
||||||
afterSecond := time.Now()
|
afterSecond := time.Now()
|
||||||
|
|
||||||
// Get updated versions
|
// Get updated versions
|
||||||
@@ -232,56 +268,59 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
require.Len(t, versions, 2)
|
require.Len(t, versions, 2)
|
||||||
|
|
||||||
// Reload first version metadata (should have notAfter now)
|
// Reload first version metadata (should have notAfter now)
|
||||||
firstVersion = secret.NewVersion(vault, secretName, versions[1])
|
firstVersion = secret.NewVersion(vault, testSecretPath, versions[1])
|
||||||
err = firstVersion.LoadMetadata(ltIdentity)
|
err = firstVersion.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
assert.NotNil(t, firstVersion.Metadata.NotAfter)
|
||||||
assert.True(t, firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
assert.True(t,
|
||||||
assert.True(t, firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
firstVersion.Metadata.NotAfter.After(beforeSecond.Add(-time.Second)))
|
||||||
|
assert.True(t,
|
||||||
|
firstVersion.Metadata.NotAfter.Before(afterSecond.Add(time.Second)))
|
||||||
|
|
||||||
// Check second version timestamps
|
// Check second version timestamps
|
||||||
secondVersion := secret.NewVersion(vault, secretName, versions[0])
|
secondVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||||
err = secondVersion.LoadMetadata(ltIdentity)
|
err = secondVersion.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
assert.NotNil(t, secondVersion.Metadata.NotBefore)
|
||||||
assert.True(t, secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
assert.True(t,
|
||||||
assert.True(t, secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
secondVersion.Metadata.NotBefore.After(beforeSecond.Add(-time.Second)))
|
||||||
|
assert.True(t,
|
||||||
|
secondVersion.Metadata.NotBefore.Before(afterSecond.Add(time.Second)))
|
||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
// Add a secret
|
// Add a secret
|
||||||
addTestSecretToVault(t, vault, "test/secret", []byte("value"), false)
|
addTestSecretToVault(t, vault, testSecretPath, []byte("value"), false)
|
||||||
|
|
||||||
// Try to get non-existent version
|
// Try to get non-existent version
|
||||||
_, err := vault.GetSecretVersion("test/secret", "20991231.999")
|
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
|
||||||
assert.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
assert.Contains(t, err.Error(), "not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
||||||
func TestUpdateVersionMetadata(t *testing.T) {
|
func TestUpdateVersionMetadata(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
vault := createTestVaultWithKey(t, fs, stateDir, "test")
|
vault := createTestVaultWithKey(t, fs)
|
||||||
|
|
||||||
// Get long-term key
|
// Get long-term key
|
||||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create a version manually to test updateVersionMetadata
|
// Create a version manually to test updateVersionMetadata
|
||||||
secretName := "test/secret"
|
|
||||||
versionName := "20231215.001"
|
versionName := "20231215.001"
|
||||||
version := secret.NewVersion(vault, secretName, versionName)
|
version := secret.NewVersion(vault, testSecretPath, versionName)
|
||||||
|
|
||||||
// Set initial metadata
|
// Set initial metadata
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -292,6 +331,7 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
|||||||
// Save version
|
// Save version
|
||||||
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer testBuffer.Destroy()
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
err = version.Save(testBuffer)
|
err = version.Save(testBuffer)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -301,7 +341,7 @@ func TestUpdateVersionMetadata(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Load and verify
|
// Load and verify
|
||||||
version2 := secret.NewVersion(vault, secretName, versionName)
|
version2 := secret.NewVersion(vault, testSecretPath, versionName)
|
||||||
err = version2.LoadMetadata(ltIdentity)
|
err = version2.LoadMetadata(ltIdentity)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -14,13 +14,22 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Unlocker metadata type strings.
|
||||||
|
const (
|
||||||
|
unlockerTypePassphrase = "passphrase"
|
||||||
|
unlockerTypeSecureEnclave = "secure-enclave"
|
||||||
|
)
|
||||||
|
|
||||||
// GetCurrentUnlocker returns the current unlocker for this vault
|
// GetCurrentUnlocker returns the current unlocker for this vault
|
||||||
|
//
|
||||||
|
//nolint:ireturn // returns one of several concrete unlocker implementations
|
||||||
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||||
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
|
secret.DebugWith("Getting current unlocker", slog.String("vault_name", v.Name))
|
||||||
|
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get vault directory for unlocker", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get vault directory for unlocker",
|
||||||
|
"error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -30,7 +39,8 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
// Check if the symlink exists
|
// Check if the symlink exists
|
||||||
_, err = v.fs.Stat(currentUnlockerPath)
|
_, err = v.fs.Stat(currentUnlockerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to stat current unlocker symlink", "error", err, "path", currentUnlockerPath)
|
secret.Debug("Failed to stat current unlocker symlink",
|
||||||
|
"error", err, "path", currentUnlockerPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to read current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -47,49 +57,37 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Read unlocker metadata
|
// Read unlocker metadata
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
metadata, err := v.readUnlockerMetadata(unlockerDir)
|
||||||
secret.Debug("Reading unlocker metadata", "path", metadataPath)
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
return nil, err
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.DebugWith("Parsed unlocker metadata",
|
|
||||||
slog.String("unlocker_type", metadata.Type),
|
|
||||||
slog.Time("created_at", metadata.CreatedAt),
|
|
||||||
slog.Any("flags", metadata.Flags),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create unlocker instance using direct constructors with filesystem
|
// Create unlocker instance using direct constructors with filesystem
|
||||||
var unlocker secret.Unlocker
|
var unlocker secret.Unlocker
|
||||||
// Use metadata directly as it's already the correct type
|
// Use metadata directly as it's already the correct type
|
||||||
switch metadata.Type {
|
switch metadata.Type {
|
||||||
case "passphrase":
|
case unlockerTypePassphrase:
|
||||||
secret.Debug("Creating passphrase unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating passphrase unlocker instance",
|
||||||
|
"unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case "pgp":
|
case "pgp":
|
||||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case "keychain":
|
case "keychain":
|
||||||
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
|
||||||
case "secure-enclave":
|
case unlockerTypeSecureEnclave:
|
||||||
secret.Debug("Creating secure enclave unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating secure enclave unlocker instance",
|
||||||
|
"unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
unlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDir, metadata)
|
||||||
default:
|
default:
|
||||||
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
secret.Debug("Unsupported unlocker type", "type", metadata.Type)
|
||||||
|
|
||||||
return nil, fmt.Errorf("unsupported unlocker type: %s", metadata.Type)
|
return nil, fmt.Errorf("%w: %s", ErrUnsupportedUnlockerType, metadata.Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Successfully created unlocker instance",
|
secret.DebugWith("Successfully created unlocker instance",
|
||||||
@@ -101,14 +99,16 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return unlocker, nil
|
return unlocker, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveUnlockerDirectory reads the current-unlocker file to get the unlocker directory path
|
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
||||||
|
// unlocker directory path
|
||||||
// The file contains just the unlocker name (e.g., "passphrase")
|
// The file contains just the unlocker name (e.g., "passphrase")
|
||||||
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
||||||
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
||||||
|
|
||||||
unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
unlockerNameBytes, err := afero.ReadFile(v.fs, currentUnlockerPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to read current-unlocker file", "error", err, "path", currentUnlockerPath)
|
secret.Debug("Failed to read current-unlocker file",
|
||||||
|
"error", err, "path", currentUnlockerPath)
|
||||||
|
|
||||||
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
return "", fmt.Errorf("failed to read current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -125,8 +125,13 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
|||||||
return absolutePath, nil
|
return absolutePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker instance and its directory path
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||||
func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlocker, string, error) {
|
// instance and its directory path
|
||||||
|
//
|
||||||
|
//nolint:ireturn // returns one of several concrete unlocker implementations
|
||||||
|
func (v *Vault) findUnlockerByID(
|
||||||
|
unlockersDir, unlockerID string,
|
||||||
|
) (secret.Unlocker, string, error) {
|
||||||
files, err := afero.ReadDir(v.fs, unlockersDir)
|
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
@@ -139,10 +144,14 @@ func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlock
|
|||||||
|
|
||||||
// Read metadata file
|
// Read metadata file
|
||||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
||||||
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
return nil, "", fmt.Errorf(
|
||||||
|
"failed to check if metadata exists for unlocker %s: %w",
|
||||||
|
file.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
// Skip directories without metadata - they might not be unlockers
|
// Skip directories without metadata - they might not be unlockers
|
||||||
continue
|
continue
|
||||||
@@ -150,26 +159,31 @@ func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlock
|
|||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
return nil, "", fmt.Errorf(
|
||||||
|
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
var metadata UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||||
|
|
||||||
// Create the appropriate unlocker instance
|
// Create the appropriate unlocker instance
|
||||||
var tempUnlocker secret.Unlocker
|
var tempUnlocker secret.Unlocker
|
||||||
|
|
||||||
switch metadata.Type {
|
switch metadata.Type {
|
||||||
case "passphrase":
|
case unlockerTypePassphrase:
|
||||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case "pgp":
|
case "pgp":
|
||||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case "keychain":
|
case "keychain":
|
||||||
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
case "secure-enclave":
|
case unlockerTypeSecureEnclave:
|
||||||
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
@@ -198,6 +212,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
|
return nil, fmt.Errorf("failed to check if unlockers directory exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []UnlockerMetadata{}, nil
|
return []UnlockerMetadata{}, nil
|
||||||
}
|
}
|
||||||
@@ -209,28 +224,39 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var unlockers []UnlockerMetadata
|
var unlockers []UnlockerMetadata
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if file.IsDir() {
|
if file.IsDir() {
|
||||||
// Read metadata file
|
// Read metadata file
|
||||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
metadataPath := filepath.Join(unlockersDir, file.Name(),
|
||||||
|
"unlocker-metadata.json")
|
||||||
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to check if metadata exists for unlocker %s: %w", file.Name(), err)
|
return nil, fmt.Errorf(
|
||||||
|
"failed to check if metadata exists for unlocker %s: %w",
|
||||||
|
file.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
secret.Warn("Skipping unlocker directory with missing metadata file", "directory", file.Name())
|
secret.Warn("Skipping unlocker directory with missing metadata file",
|
||||||
|
"directory", file.Name())
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read metadata for unlocker %s: %w", file.Name(), err)
|
return nil, fmt.Errorf(
|
||||||
|
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
var metadata UnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockers = append(unlockers, metadata)
|
unlockers = append(unlockers, metadata)
|
||||||
@@ -257,7 +283,7 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if unlocker == nil {
|
if unlocker == nil {
|
||||||
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the unlocker's Remove method
|
// Use the unlocker's Remove method
|
||||||
@@ -281,17 +307,21 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if targetUnlockerDir == "" {
|
if targetUnlockerDir == "" {
|
||||||
return fmt.Errorf("unlocker with ID %s not found", unlockerID)
|
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create/update current-unlocker file with just the unlocker name
|
// Create/update current-unlocker file with just the unlocker name
|
||||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
// Remove existing file if it exists
|
// Remove existing file if it exists
|
||||||
if exists, err := afero.Exists(v.fs, currentUnlockerPath); err != nil {
|
exists, err := afero.Exists(v.fs, currentUnlockerPath)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
return fmt.Errorf("failed to check if current-unlocker file exists: %w", err)
|
||||||
} else if exists {
|
}
|
||||||
if err := v.fs.Remove(currentUnlockerPath); err != nil {
|
|
||||||
|
if exists {
|
||||||
|
err = v.fs.Remove(currentUnlockerPath)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
return fmt.Errorf("failed to remove existing current-unlocker file: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,7 +331,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
|
|
||||||
// Write just the unlocker name to the file
|
// Write just the unlocker name to the file
|
||||||
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
secret.Debug("Writing current-unlocker file", "unlocker_name", unlockerName)
|
||||||
if err := afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName), secret.FilePerms); err != nil {
|
|
||||||
|
err = afero.WriteFile(v.fs, currentUnlockerPath, []byte(unlockerName),
|
||||||
|
secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
return fmt.Errorf("failed to create current-unlocker file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,15 +343,19 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
|
|
||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
||||||
// The passphrase must be provided as a LockedBuffer for security
|
// The passphrase must be provided as a LockedBuffer for security
|
||||||
func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) {
|
func (v *Vault) CreatePassphraseUnlocker(
|
||||||
|
passphrase *memguard.LockedBuffer,
|
||||||
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create unlocker directory
|
// Create unlocker directory
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
|
||||||
if err := v.fs.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
|
||||||
|
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,32 +365,15 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
|
|||||||
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write public key
|
// Write the unlocker keypair (public and passphrase-encrypted private)
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
|
||||||
if err := afero.WriteFile(v.fs, pubKeyPath,
|
|
||||||
[]byte(unlockerIdentity.Recipient().String()),
|
|
||||||
secret.FilePerms); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write unlocker public key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt private key with passphrase
|
|
||||||
privKeyStr := unlockerIdentity.String()
|
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
|
||||||
defer privKeyBuffer.Destroy()
|
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
return nil, err
|
||||||
}
|
|
||||||
|
|
||||||
// Write encrypted private key
|
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
|
||||||
if err := afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create metadata
|
// Create metadata
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "passphrase",
|
Type: unlockerTypePassphrase,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Flags: []string{},
|
Flags: []string{},
|
||||||
}
|
}
|
||||||
@@ -365,7 +385,9 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
if err := afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
|
||||||
|
err = afero.WriteFile(v.fs, metadataPath, metadataBytes, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,13 +401,16 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
|
|||||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
defer ltPrivKeyBuffer.Destroy()
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, unlockerIdentity.Recipient())
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
|
||||||
|
unlockerIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
if err := afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms); err != nil {
|
|
||||||
|
err = afero.WriteFile(v.fs, ltPrivKeyPath, encryptedLtPrivKey, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,9 +418,80 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*se
|
|||||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
|
|
||||||
// Select this unlocker as current
|
// Select this unlocker as current
|
||||||
if err := v.SelectUnlocker(unlocker.GetID()); err != nil {
|
err = v.SelectUnlocker(unlocker.GetID())
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return unlocker, nil
|
return unlocker, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in
|
||||||
|
// the given unlocker directory.
|
||||||
|
func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, error) {
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
secret.Debug("Reading unlocker metadata", "path", metadataPath)
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to read unlocker metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
|
return metadata, fmt.Errorf("failed to read unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to parse unlocker metadata", "error", err, "path", metadataPath)
|
||||||
|
|
||||||
|
return metadata, fmt.Errorf("failed to parse unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.DebugWith("Parsed unlocker metadata",
|
||||||
|
slog.String("unlocker_type", metadata.Type),
|
||||||
|
slog.Time("created_at", metadata.CreatedAt),
|
||||||
|
slog.Any("flags", metadata.Flags),
|
||||||
|
)
|
||||||
|
|
||||||
|
return metadata, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeUnlockerKeypair writes the unlocker's public key and its
|
||||||
|
// passphrase-encrypted private key into the unlocker directory.
|
||||||
|
func (v *Vault) writeUnlockerKeypair(
|
||||||
|
unlockerDir string,
|
||||||
|
unlockerIdentity *age.X25519Identity,
|
||||||
|
passphrase *memguard.LockedBuffer,
|
||||||
|
) error {
|
||||||
|
// Write public key
|
||||||
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||||
|
|
||||||
|
err := afero.WriteFile(v.fs, pubKeyPath,
|
||||||
|
[]byte(unlockerIdentity.Recipient().String()),
|
||||||
|
secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt private key with passphrase
|
||||||
|
privKeyStr := unlockerIdentity.String()
|
||||||
|
|
||||||
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to encrypt unlocker private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write encrypted private key
|
||||||
|
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
|
|
||||||
|
err = afero.WriteFile(v.fs, privKeyPath, encryptedPrivKey, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ type Vault struct {
|
|||||||
// NewVault creates a new Vault instance
|
// NewVault creates a new Vault instance
|
||||||
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
func NewVault(fs afero.Fs, stateDir string, name string) *Vault {
|
||||||
secret.Debug("Creating NewVault instance")
|
secret.Debug("Creating NewVault instance")
|
||||||
|
|
||||||
v := &Vault{
|
v := &Vault{
|
||||||
Name: name,
|
Name: name,
|
||||||
fs: fs,
|
fs: fs,
|
||||||
stateDir: stateDir,
|
stateDir: stateDir,
|
||||||
longTermKey: nil,
|
longTermKey: nil,
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Created NewVault instance successfully")
|
secret.Debug("Created NewVault instance successfully")
|
||||||
|
|
||||||
return v
|
return v
|
||||||
@@ -54,7 +56,8 @@ func (v *Vault) ClearLongTermKey() {
|
|||||||
v.longTermKey = nil
|
v.longTermKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it from available sources
|
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
||||||
|
// from available sources
|
||||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
// If we have it in memory, return it
|
// If we have it in memory, return it
|
||||||
if !v.Locked() {
|
if !v.Locked() {
|
||||||
@@ -65,55 +68,12 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Try to derive from environment mnemonic first
|
// Try to derive from environment mnemonic first
|
||||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
||||||
secret.Debug("Using mnemonic from environment for long-term key derivation", "vault_name", v.Name)
|
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
|
||||||
|
|
||||||
// Load vault metadata to get the derivation index
|
|
||||||
vaultDir, err := v.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to derive long-term key from mnemonic", "error", err, "vault_name", v.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify that the derived key matches the stored public key hash
|
|
||||||
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
|
||||||
if derivedPubKeyHash != metadata.PublicKeyHash {
|
|
||||||
secret.Debug("Derived public key hash does not match stored hash",
|
|
||||||
"vault_name", v.Name,
|
|
||||||
"derived_hash", derivedPubKeyHash,
|
|
||||||
"stored_hash", metadata.PublicKeyHash,
|
|
||||||
"derivation_index", metadata.DerivationIndex)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("derived public key does not match vault: mnemonic may be incorrect")
|
|
||||||
}
|
|
||||||
|
|
||||||
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
|
||||||
slog.String("vault_name", v.Name),
|
|
||||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
|
||||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Cache the derived key by unlocking the vault
|
|
||||||
v.Unlock(ltIdentity)
|
|
||||||
secret.Debug("Vault is unlocked (lt key in memory) via mnemonic", "vault_name", v.Name)
|
|
||||||
|
|
||||||
return ltIdentity, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mnemonic available, try to use current unlocker
|
// No mnemonic available, try to use current unlocker
|
||||||
secret.Debug("No mnemonic available, using current unlocker to unlock vault", "vault_name", v.Name)
|
secret.Debug("No mnemonic available, using current unlocker to unlock vault",
|
||||||
|
"vault_name", v.Name)
|
||||||
|
|
||||||
// Get current unlocker
|
// Get current unlocker
|
||||||
unlocker, err := v.GetCurrentUnlocker()
|
unlocker, err := v.GetCurrentUnlocker()
|
||||||
@@ -151,10 +111,130 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
return ltIdentity, nil
|
return ltIdentity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockLongTermKey extracts the vault's long-term key using the given unlocker.
|
// GetDirectory returns the vault's directory path
|
||||||
// SE unlockers decrypt the long-term key directly; other unlockers use an intermediate identity.
|
func (v *Vault) GetDirectory() (string, error) {
|
||||||
func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity, error) {
|
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
||||||
if unlocker.GetType() == "secure-enclave" {
|
}
|
||||||
|
|
||||||
|
// GetName returns the vault's name (for VaultInterface compatibility)
|
||||||
|
func (v *Vault) GetName() string {
|
||||||
|
return v.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFilesystem returns the vault's filesystem (for VaultInterface
|
||||||
|
// compatibility)
|
||||||
|
//
|
||||||
|
//nolint:ireturn // afero.Fs is the interface required by VaultInterface
|
||||||
|
func (v *Vault) GetFilesystem() afero.Fs {
|
||||||
|
return v.fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// NumSecrets returns the number of secrets in the vault
|
||||||
|
func (v *Vault) NumSecrets() (int, error) {
|
||||||
|
vaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
|
exists, _ := afero.DirExists(v.fs, secretsDir)
|
||||||
|
if !exists {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := afero.ReadDir(v.fs, secretsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count only directories that have a "current" version pointer file
|
||||||
|
count := 0
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// A valid secret has a "current" file pointing to the active version
|
||||||
|
secretDir := filepath.Join(secretsDir, entry.Name())
|
||||||
|
currentFile := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
|
exists, err := afero.Exists(v.fs, currentFile)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip directories we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
|
||||||
|
// mnemonic, verifies it against the vault metadata, and caches it in memory.
|
||||||
|
func (v *Vault) deriveLongTermKeyFromMnemonic(
|
||||||
|
envMnemonic string,
|
||||||
|
) (*age.X25519Identity, error) {
|
||||||
|
secret.Debug("Using mnemonic from environment for long-term key derivation",
|
||||||
|
"vault_name", v.Name)
|
||||||
|
|
||||||
|
// Load vault metadata to get the derivation index
|
||||||
|
vaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := LoadVaultMetadata(v.fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to load vault metadata", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to derive long-term key from mnemonic",
|
||||||
|
"error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the derived key matches the stored public key hash
|
||||||
|
derivedPubKeyHash := ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||||
|
if derivedPubKeyHash != metadata.PublicKeyHash {
|
||||||
|
secret.Debug("Derived public key hash does not match stored hash",
|
||||||
|
"vault_name", v.Name,
|
||||||
|
"derived_hash", derivedPubKeyHash,
|
||||||
|
"stored_hash", metadata.PublicKeyHash,
|
||||||
|
"derivation_index", metadata.DerivationIndex)
|
||||||
|
|
||||||
|
return nil, ErrMnemonicMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.DebugWith("Successfully derived long-term key from mnemonic",
|
||||||
|
slog.String("vault_name", v.Name),
|
||||||
|
slog.String("public_key", ltIdentity.Recipient().String()),
|
||||||
|
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Cache the derived key by unlocking the vault
|
||||||
|
v.Unlock(ltIdentity)
|
||||||
|
secret.Debug("Vault is unlocked (lt key in memory) via mnemonic",
|
||||||
|
"vault_name", v.Name)
|
||||||
|
|
||||||
|
return ltIdentity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlockLongTermKey extracts the vault's long-term key using the given
|
||||||
|
// unlocker. SE unlockers decrypt the long-term key directly; other unlockers
|
||||||
|
// use an intermediate identity.
|
||||||
|
func (v *Vault) unlockLongTermKey(
|
||||||
|
unlocker secret.Unlocker,
|
||||||
|
) (*age.X25519Identity, error) {
|
||||||
|
if unlocker.GetType() == unlockerTypeSecureEnclave {
|
||||||
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
secret.Debug("SE unlocker: decrypting long-term key directly via Secure Enclave")
|
||||||
|
|
||||||
ltIdentity, err := unlocker.GetIdentity()
|
ltIdentity, err := unlocker.GetIdentity()
|
||||||
@@ -178,7 +258,8 @@ func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity
|
|||||||
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
|
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(
|
||||||
|
encryptedLtPrivKey, unlockerIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -191,59 +272,3 @@ func (v *Vault) unlockLongTermKey(unlocker secret.Unlocker) (*age.X25519Identity
|
|||||||
|
|
||||||
return ltIdentity, nil
|
return ltIdentity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDirectory returns the vault's directory path
|
|
||||||
func (v *Vault) GetDirectory() (string, error) {
|
|
||||||
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetName returns the vault's name (for VaultInterface compatibility)
|
|
||||||
func (v *Vault) GetName() string {
|
|
||||||
return v.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFilesystem returns the vault's filesystem (for VaultInterface compatibility)
|
|
||||||
func (v *Vault) GetFilesystem() afero.Fs {
|
|
||||||
return v.fs
|
|
||||||
}
|
|
||||||
|
|
||||||
// NumSecrets returns the number of secrets in the vault
|
|
||||||
func (v *Vault) NumSecrets() (int, error) {
|
|
||||||
vaultDir, err := v.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
|
||||||
exists, _ := afero.DirExists(v.fs, secretsDir)
|
|
||||||
if !exists {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := afero.ReadDir(v.fs, secretsDir)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to read secrets directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count only directories that have a "current" version pointer file
|
|
||||||
count := 0
|
|
||||||
for _, entry := range entries {
|
|
||||||
if !entry.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// A valid secret has a "current" file pointing to the active version
|
|
||||||
secretDir := filepath.Join(secretsDir, entry.Name())
|
|
||||||
currentFile := filepath.Join(secretDir, "current")
|
|
||||||
exists, err := afero.Exists(v.fs, currentFile)
|
|
||||||
if err != nil {
|
|
||||||
continue // Skip directories we can't read
|
|
||||||
}
|
|
||||||
|
|
||||||
if exists {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,32 +13,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create in-memory filesystem
|
// Create in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create a vault directory without a public key (simulating the error condition)
|
// Create a vault directory without a public key (simulating the error
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "broken")
|
// condition)
|
||||||
|
vaultDir := filepath.Join(testStateDir, "vaults.d", "broken")
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
||||||
|
|
||||||
// Create currentvault symlink
|
// Create currentvault symlink
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
require.NoError(t,
|
||||||
|
afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||||
|
|
||||||
// Create vault instance
|
// Create vault instance
|
||||||
vlt := vault.NewVault(fs, stateDir, "broken")
|
vlt := vault.NewVault(fs, testStateDir, "broken")
|
||||||
|
|
||||||
// Try to add a secret - this should fail
|
// Try to add a secret - this should fail
|
||||||
secretName := "test-secret"
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer value.Destroy()
|
defer value.Destroy()
|
||||||
|
|
||||||
err := vlt.AddSecret(secretName, value, false)
|
err := vlt.AddSecret(testSecretName, value, false)
|
||||||
require.Error(t, err, "AddSecret should fail when public key is missing")
|
require.Error(t, err, "AddSecret should fail when public key is missing")
|
||||||
assert.Contains(t, err.Error(), "failed to read long-term public key")
|
assert.Contains(t, err.Error(), "failed to read long-term public key")
|
||||||
|
|
||||||
// Verify that the secret directory was NOT created
|
// Verify that the secret directory was NOT created
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
|
||||||
exists, _ := afero.DirExists(fs, secretDir)
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
|
|
||||||
@@ -47,41 +49,45 @@ func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
|
|||||||
if exists, _ := afero.DirExists(fs, secretsDir); exists {
|
if exists, _ := afero.DirExists(fs, secretsDir); exists {
|
||||||
entries, err := afero.ReadDir(fs, secretsDir)
|
entries, err := afero.ReadDir(fs, secretsDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Empty(t, entries, "secrets.d directory should be empty after failed AddSecret")
|
assert.Empty(t, entries,
|
||||||
|
"secrets.d directory should be empty after failed AddSecret")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create in-memory filesystem
|
// Create in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create a vault directory with public key
|
// Create a vault directory with public key
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "test")
|
vaultDir := filepath.Join(testStateDir, "vaults.d", "test")
|
||||||
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
require.NoError(t, fs.MkdirAll(vaultDir, secret.DirPerms))
|
||||||
|
|
||||||
// Create a mock public key that will cause encryption to fail
|
// Create a mock public key that will cause encryption to fail
|
||||||
// by using an invalid age public key format
|
// by using an invalid age public key format
|
||||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
require.NoError(t, afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"), secret.FilePerms))
|
require.NoError(t,
|
||||||
|
afero.WriteFile(fs, pubKeyPath, []byte("invalid-public-key"),
|
||||||
|
secret.FilePerms))
|
||||||
|
|
||||||
// Create currentvault symlink
|
// Create currentvault symlink
|
||||||
currentVaultPath := filepath.Join(stateDir, "currentvault")
|
currentVaultPath := filepath.Join(testStateDir, "currentvault")
|
||||||
require.NoError(t, afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
require.NoError(t,
|
||||||
|
afero.WriteFile(fs, currentVaultPath, []byte(vaultDir), secret.FilePerms))
|
||||||
|
|
||||||
// Create vault instance
|
// Create vault instance
|
||||||
vlt := vault.NewVault(fs, stateDir, "test")
|
vlt := vault.NewVault(fs, testStateDir, "test")
|
||||||
|
|
||||||
// Try to add a secret - this should fail during encryption
|
// Try to add a secret - this should fail during encryption
|
||||||
secretName := "test-secret"
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
value := memguard.NewBufferFromBytes([]byte("test-value"))
|
||||||
defer value.Destroy()
|
defer value.Destroy()
|
||||||
|
|
||||||
err := vlt.AddSecret(secretName, value, false)
|
err := vlt.AddSecret(testSecretName, value, false)
|
||||||
require.Error(t, err, "AddSecret should fail with invalid public key")
|
require.Error(t, err, "AddSecret should fail with invalid public key")
|
||||||
|
|
||||||
// Verify that the secret directory was NOT created
|
// Verify that the secret directory was NOT created
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", secretName)
|
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
|
||||||
exists, _ := afero.DirExists(fs, secretDir)
|
exists, _ := afero.DirExists(fs, secretDir)
|
||||||
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
assert.False(t, exists, "Secret directory should not exist after failed AddSecret")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +1,301 @@
|
|||||||
package vault
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testMnemonic is the shared BIP39 test mnemonic for tests in this package.
|
||||||
|
//
|
||||||
|
//nolint:dupword // BIP39 test mnemonic intentionally repeats a word
|
||||||
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// Shared fixtures for tests in this package.
|
||||||
|
const (
|
||||||
|
testStateDir = "/test/state"
|
||||||
|
testVaultName = "test-vault"
|
||||||
|
testSecretName = "test-secret"
|
||||||
|
testPassphrase = "test-passphrase"
|
||||||
|
)
|
||||||
|
|
||||||
|
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
|
||||||
func TestVaultOperations(t *testing.T) {
|
func TestVaultOperations(t *testing.T) {
|
||||||
// Test environment will be cleaned up automatically by t.Setenv
|
// Test environment will be cleaned up automatically by t.Setenv
|
||||||
|
|
||||||
// Set test environment variables
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Test vault creation
|
|
||||||
t.Run("CreateVault", func(t *testing.T) {
|
t.Run("CreateVault", func(t *testing.T) {
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
testCreateVault(t, fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if vlt.GetName() != "test-vault" {
|
|
||||||
t.Errorf("Expected vault name 'test-vault', got '%s'", vlt.GetName())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check vault directory exists
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
exists, err := afero.DirExists(fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
t.Errorf("Vault directory should exist")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test vault listing
|
|
||||||
t.Run("ListVaults", func(t *testing.T) {
|
t.Run("ListVaults", func(t *testing.T) {
|
||||||
vaults, err := ListVaults(fs, stateDir)
|
testListVaults(t, fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list vaults: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, vault := range vaults {
|
|
||||||
if vault == "test-vault" {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Expected to find 'test-vault' in vault list")
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test vault selection
|
|
||||||
t.Run("SelectVault", func(t *testing.T) {
|
t.Run("SelectVault", func(t *testing.T) {
|
||||||
err := SelectVault(fs, stateDir, "test-vault")
|
testSelectVault(t, fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to select vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test getting current vault
|
|
||||||
currentVault, err := GetCurrentVault(fs, stateDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentVault.GetName() != "test-vault" {
|
|
||||||
t.Errorf("Expected current vault 'test-vault', got '%s'", currentVault.GetName())
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test secret operations
|
|
||||||
t.Run("SecretOperations", func(t *testing.T) {
|
t.Run("SecretOperations", func(t *testing.T) {
|
||||||
vlt, err := GetCurrentVault(fs, stateDir)
|
testSecretOperations(t, fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// First, derive the long-term key from the test mnemonic
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the public key from the derived identity
|
|
||||||
ltPublicKey := ltIdentity.Recipient().String()
|
|
||||||
|
|
||||||
// Get the vault directory
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the correct public key to the pub.age file
|
|
||||||
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
||||||
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the vault with the derived identity
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Now add a secret
|
|
||||||
secretName := "test/secret"
|
|
||||||
secretValue := []byte("test-secret-value")
|
|
||||||
expectedValue := make([]byte, len(secretValue))
|
|
||||||
copy(expectedValue, secretValue)
|
|
||||||
|
|
||||||
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
|
||||||
defer secretBuffer.Destroy()
|
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secretBuffer, false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to add secret: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List secrets
|
|
||||||
secrets, err := vlt.ListSecrets()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list secrets: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, secret := range secrets {
|
|
||||||
if secret == secretName {
|
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
t.Errorf("Expected to find secret '%s' in list", secretName)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get secret value
|
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get secret: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if string(retrievedValue) != string(expectedValue) {
|
|
||||||
t.Errorf("Expected secret value '%s', got '%s'", string(expectedValue), string(retrievedValue))
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test NumSecrets
|
|
||||||
t.Run("NumSecrets", func(t *testing.T) {
|
t.Run("NumSecrets", func(t *testing.T) {
|
||||||
vlt, err := GetCurrentVault(fs, stateDir)
|
testNumSecrets(t, fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
numSecrets, err := vlt.NumSecrets()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to count secrets: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We added one secret in SecretOperations
|
|
||||||
if numSecrets != 1 {
|
|
||||||
t.Errorf("Expected 1 secret, got %d", numSecrets)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test unlocker operations
|
|
||||||
t.Run("UnlockerOperations", func(t *testing.T) {
|
t.Run("UnlockerOperations", func(t *testing.T) {
|
||||||
vlt, err := GetCurrentVault(fs, stateDir)
|
testUnlockerOperations(t, fs)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get current vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test vault unlocking (should happen automatically via mnemonic)
|
|
||||||
if vlt.Locked() {
|
|
||||||
_, err := vlt.UnlockVault()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to unlock vault: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a passphrase unlocker
|
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// List unlockers
|
|
||||||
unlockers, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list unlockers: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(unlockers) == 0 {
|
|
||||||
t.Errorf("Expected at least one unlocker")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check key type
|
|
||||||
keyFound := false
|
|
||||||
for _, key := range unlockers {
|
|
||||||
if key.Type == "passphrase" {
|
|
||||||
keyFound = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !keyFound {
|
|
||||||
t.Errorf("Expected to find passphrase unlocker")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test selecting unlocker
|
|
||||||
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to select unlocker: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test getting current unlocker
|
|
||||||
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get current unlocker: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
|
||||||
t.Errorf("Expected current unlocker ID '%s', got '%s'", passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCreateVault(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vlt.GetName() != testVaultName {
|
||||||
|
t.Errorf("Expected vault name '%s', got '%s'", testVaultName, vlt.GetName())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check vault directory exists
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
t.Errorf("Vault directory should exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testListVaults(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaults, err := vault.ListVaults(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list vaults: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(vaults, testVaultName) {
|
||||||
|
t.Errorf("Expected to find '%s' in vault list", testVaultName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSelectVault(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
err := vault.SelectVault(fs, testStateDir, testVaultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to select vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getting current vault
|
||||||
|
currentVault, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentVault.GetName() != testVaultName {
|
||||||
|
t.Errorf("Expected current vault '%s', got '%s'",
|
||||||
|
testVaultName, currentVault.GetName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSecretOperations(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, derive the long-term key from the test mnemonic
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the public key from the derived identity
|
||||||
|
ltPublicKey := ltIdentity.Recipient().String()
|
||||||
|
|
||||||
|
// Get the vault directory
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the correct public key to the pub.age file
|
||||||
|
pubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the vault with the derived identity
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Now add a secret
|
||||||
|
secretName := "test/secret"
|
||||||
|
secretValue := []byte("test-secret-value")
|
||||||
|
expectedValue := make([]byte, len(secretValue))
|
||||||
|
copy(expectedValue, secretValue)
|
||||||
|
|
||||||
|
secretBuffer := memguard.NewBufferFromBytes(secretValue)
|
||||||
|
defer secretBuffer.Destroy()
|
||||||
|
|
||||||
|
err = vlt.AddSecret(secretName, secretBuffer, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to add secret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List secrets
|
||||||
|
secrets, err := vlt.ListSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list secrets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(secrets, secretName) {
|
||||||
|
t.Errorf("Expected to find secret '%s' in list", secretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get secret value
|
||||||
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get secret: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if string(retrievedValue) != string(expectedValue) {
|
||||||
|
t.Errorf("Expected secret value '%s', got '%s'",
|
||||||
|
string(expectedValue), string(retrievedValue))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testNumSecrets(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
numSecrets, err := vlt.NumSecrets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to count secrets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// We added one secret in SecretOperations
|
||||||
|
if numSecrets != 1 {
|
||||||
|
t.Errorf("Expected 1 secret, got %d", numSecrets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test vault unlocking (should happen automatically via mnemonic)
|
||||||
|
if vlt.Locked() {
|
||||||
|
_, err := vlt.UnlockVault()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to unlock vault: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a passphrase unlocker
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List unlockers
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list unlockers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(unlockers) == 0 {
|
||||||
|
t.Errorf("Expected at least one unlocker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check key type
|
||||||
|
keyFound := false
|
||||||
|
|
||||||
|
for _, key := range unlockers {
|
||||||
|
if key.Type == "passphrase" {
|
||||||
|
keyFound = true
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keyFound {
|
||||||
|
t.Errorf("Expected to find passphrase unlocker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test selecting unlocker
|
||||||
|
err = vlt.SelectUnlocker(passphraseUnlocker.GetID())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to select unlocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getting current unlocker
|
||||||
|
currentUnlocker, err := vlt.GetCurrentUnlocker()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get current unlocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
|
||||||
|
t.Errorf("Expected current unlocker ID '%s', got '%s'",
|
||||||
|
passphraseUnlocker.GetID(), currentUnlocker.GetID())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := "/test/state"
|
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := CreateVault(fs, stateDir, "test-vault")
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a passphrase unlocker so we have at least one valid unlocker
|
// Create a passphrase unlocker so we have at least one valid unlocker
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
defer passphraseBuffer.Destroy()
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
@@ -273,7 +306,9 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
|
||||||
|
|
||||||
err = fs.MkdirAll(bogusDir, 0o700)
|
err = fs.MkdirAll(bogusDir, 0o700)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create bogus directory: %v", err)
|
t.Fatalf("Failed to create bogus directory: %v", err)
|
||||||
@@ -282,7 +317,8 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
// ListUnlockers should succeed, skipping the bogus directory
|
// ListUnlockers should succeed, skipping the bogus directory
|
||||||
unlockers, err := vlt.ListUnlockers()
|
unlockers, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ListUnlockers returned error when it should have skipped bad directory: %v", err)
|
t.Fatalf("ListUnlockers returned error when it should have skipped "+
|
||||||
|
"bad directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should still have the valid passphrase unlocker
|
// Should still have the valid passphrase unlocker
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
package agehd
|
package agehd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -28,6 +29,10 @@ const (
|
|||||||
x25519KeySize = 32 // 256-bit key size for X25519
|
x25519KeySize = 32 // 256-bit key size for X25519
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// errInvalidScalarSize is returned when the entropy is not exactly 32
|
||||||
|
// bytes long.
|
||||||
|
var errInvalidScalarSize = errors.New("need 32-byte scalar")
|
||||||
|
|
||||||
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
// clamp applies RFC-7748 clamping to a 32-byte scalar.
|
||||||
func clamp(k []byte) {
|
func clamp(k []byte) {
|
||||||
k[0] &= 248
|
k[0] &= 248
|
||||||
@@ -39,7 +44,7 @@ func clamp(k []byte) {
|
|||||||
// *age.X25519Identity by round-tripping through Bech32.
|
// *age.X25519Identity by round-tripping through Bech32.
|
||||||
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
||||||
if len(ent) != x25519KeySize {
|
if len(ent) != x25519KeySize {
|
||||||
return nil, fmt.Errorf("need 32-byte scalar, got %d", len(ent))
|
return nil, fmt.Errorf("%w, got %d", errInvalidScalarSize, len(ent))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make a copy to avoid modifying the original
|
// Make a copy to avoid modifying the original
|
||||||
@@ -51,10 +56,12 @@ func IdentityFromEntropy(ent []byte) (*age.X25519Identity, error) {
|
|||||||
bech32BitSize8 = 8 // Standard 8-bit encoding
|
bech32BitSize8 = 8 // Standard 8-bit encoding
|
||||||
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
bech32BitSize5 = 5 // Bech32 5-bit encoding
|
||||||
)
|
)
|
||||||
|
|
||||||
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
data, err := bech32.ConvertBits(key, bech32BitSize8, bech32BitSize5, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 convert: %w", err)
|
return nil, fmt.Errorf("bech32 convert: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s, err := bech32.Encode(hrp, data)
|
s, err := bech32.Encode(hrp, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("bech32 encode: %w", err)
|
return nil, fmt.Errorf("bech32 encode: %w", err)
|
||||||
@@ -87,6 +94,7 @@ func DeriveEntropy(mnemonic string, n uint32) ([]byte, error) {
|
|||||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||||
drng := bip85.NewBIP85DRNG(entropy)
|
drng := bip85.NewBIP85DRNG(entropy)
|
||||||
key := make([]byte, x25519KeySize)
|
key := make([]byte, x25519KeySize)
|
||||||
|
|
||||||
_, err = drng.Read(key)
|
_, err = drng.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||||
@@ -116,6 +124,7 @@ func DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error) {
|
|||||||
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
// Use BIP85 DRNG to generate deterministic 32 bytes for the age key
|
||||||
drng := bip85.NewBIP85DRNG(entropy)
|
drng := bip85.NewBIP85DRNG(entropy)
|
||||||
key := make([]byte, x25519KeySize)
|
key := make([]byte, x25519KeySize)
|
||||||
|
|
||||||
_, err = drng.Read(key)
|
_, err = drng.Read(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
return nil, fmt.Errorf("failed to read from DRNG: %w", err)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -23,10 +24,10 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
|
// BIP85_MASTER_PATH is the derivation path prefix for all BIP85 applications
|
||||||
BIP85_MASTER_PATH = "m/83696968'" //nolint:revive // ALL_CAPS used for BIP85 constants
|
BIP85_MASTER_PATH = "m/83696968'" //nolint:revive // BIP85 spec naming
|
||||||
|
|
||||||
// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
|
// BIP85_KEY_HMAC_KEY is the HMAC key used for deriving the entropy
|
||||||
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // ALL_CAPS used for BIP85 constants
|
BIP85_KEY_HMAC_KEY = "bip-entropy-from-k" //nolint:revive // BIP85 spec naming
|
||||||
|
|
||||||
// AppBIP39 is the application number for BIP39 mnemonics
|
// AppBIP39 is the application number for BIP39 mnemonics
|
||||||
AppBIP39 = 39
|
AppBIP39 = 39
|
||||||
@@ -34,18 +35,50 @@ const (
|
|||||||
AppHDWIF = 2
|
AppHDWIF = 2
|
||||||
// AppXPRV is the application number for extended private key
|
// AppXPRV is the application number for extended private key
|
||||||
AppXPRV = 32
|
AppXPRV = 32
|
||||||
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
|
APP_HEX = 128169 //nolint:revive // BIP85 spec naming
|
||||||
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
|
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // BIP85 spec naming
|
||||||
AppPWD85 = 707785 // Base85 passwords
|
AppPWD85 = 707785 // Base85 passwords
|
||||||
APP_RSA = 828365 //nolint:revive // ALL_CAPS used for BIP85 constants
|
APP_RSA = 828365 //nolint:revive // BIP85 spec naming
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for BIP85 derivation.
|
||||||
|
var (
|
||||||
|
// ErrNotPrivateKey is returned when the supplied master key is not a
|
||||||
|
// private key.
|
||||||
|
ErrNotPrivateKey = errors.New("master key must be a private key")
|
||||||
|
// ErrInvalidPathComponent is returned when a derivation path component
|
||||||
|
// cannot be parsed.
|
||||||
|
ErrInvalidPathComponent = errors.New("invalid path component")
|
||||||
|
// ErrInvalidWordCount is returned for unsupported BIP39 word counts.
|
||||||
|
ErrInvalidWordCount = errors.New("invalid BIP39 word count")
|
||||||
|
// ErrInvalidNumBytes is returned when numBytes is out of range.
|
||||||
|
ErrInvalidNumBytes = errors.New("numBytes must be between 16 and 64")
|
||||||
|
// ErrInvalidBase64PwdLen is returned when the Base64 password length
|
||||||
|
// is out of range.
|
||||||
|
ErrInvalidBase64PwdLen = errors.New("pwdLen must be between 20 and 86")
|
||||||
|
// ErrInvalidBase85PwdLen is returned when the Base85 password length
|
||||||
|
// is out of range.
|
||||||
|
ErrInvalidBase85PwdLen = errors.New("pwdLen must be between 10 and 80")
|
||||||
|
// ErrPasswordTooShort is returned when the derived material is
|
||||||
|
// shorter than the requested password length. It carries only the
|
||||||
|
// middle of the message, which the caller composes as
|
||||||
|
// "derived password length <n> is shorter than requested length <m>",
|
||||||
|
// so the emitted text is unchanged.
|
||||||
|
ErrPasswordTooShort = errors.New("is shorter than requested length")
|
||||||
|
// ErrEncodedTooShort is returned when the encoded material is shorter
|
||||||
|
// than the requested password length. Composed as
|
||||||
|
// "encoded length <n> is less than requested length <m>".
|
||||||
|
ErrEncodedTooShort = errors.New("is less than requested length")
|
||||||
)
|
)
|
||||||
|
|
||||||
// Version bytes for extended keys
|
// Version bytes for extended keys
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // standard BIP32 version constants
|
||||||
var (
|
var (
|
||||||
// MainNetPrivateKey is the version for mainnet private keys
|
// MainNetPrivateKey is the version for mainnet private keys
|
||||||
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4} //nolint:gochecknoglobals // Standard BIP32 constant
|
MainNetPrivateKey = []byte{0x04, 0x88, 0xAD, 0xE4}
|
||||||
// TestNetPrivateKey is the version for testnet private keys
|
// TestNetPrivateKey is the version for testnet private keys
|
||||||
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94} //nolint:gochecknoglobals // Standard BIP32 constant
|
TestNetPrivateKey = []byte{0x04, 0x35, 0x83, 0x94}
|
||||||
)
|
)
|
||||||
|
|
||||||
// DRNG is a deterministic random number generator seeded by BIP85 entropy
|
// DRNG is a deterministic random number generator seeded by BIP85 entropy
|
||||||
@@ -71,7 +104,7 @@ func NewBIP85DRNG(entropy []byte) *DRNG {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read implements the io.Reader interface
|
// Read implements the io.Reader interface
|
||||||
func (d *DRNG) Read(p []byte) (n int, err error) {
|
func (d *DRNG) Read(p []byte) (int, error) {
|
||||||
return d.shake.Read(p)
|
return d.shake.Read(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +112,7 @@ func (d *DRNG) Read(p []byte) (n int, err error) {
|
|||||||
func DeriveChildKey(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
func DeriveChildKey(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
||||||
// Validate the masterKey is a private key
|
// Validate the masterKey is a private key
|
||||||
if !masterKey.IsPrivate() {
|
if !masterKey.IsPrivate() {
|
||||||
return nil, fmt.Errorf("master key must be a private key")
|
return nil, ErrNotPrivateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the child key at the specified path
|
// Derive the child key at the specified path
|
||||||
@@ -98,8 +131,12 @@ func DeriveChildKey(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, err
|
|||||||
return ecPrivKey.Serialize(), nil
|
return ecPrivKey.Serialize(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBIP85Entropy derives entropy from a BIP32 master key using the BIP85 method
|
// DeriveBIP85Entropy derives entropy from a BIP32 master key using the
|
||||||
func DeriveBIP85Entropy(masterKey *hdkeychain.ExtendedKey, path string) ([]byte, error) {
|
// BIP85 method
|
||||||
|
func DeriveBIP85Entropy(
|
||||||
|
masterKey *hdkeychain.ExtendedKey,
|
||||||
|
path string,
|
||||||
|
) ([]byte, error) {
|
||||||
// Get the child key bytes
|
// Get the child key bytes
|
||||||
privKeyBytes, err := DeriveChildKey(masterKey, path)
|
privKeyBytes, err := DeriveChildKey(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -115,7 +152,10 @@ func DeriveBIP85Entropy(masterKey *hdkeychain.ExtendedKey, path string) ([]byte,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// deriveChildKey derives a child key from a parent key using the given path
|
// deriveChildKey derives a child key from a parent key using the given path
|
||||||
func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.ExtendedKey, error) {
|
func deriveChildKey(
|
||||||
|
parent *hdkeychain.ExtendedKey,
|
||||||
|
path string,
|
||||||
|
) (*hdkeychain.ExtendedKey, error) {
|
||||||
if path == "" || path == "m" || path == "/" {
|
if path == "" || path == "m" || path == "/" {
|
||||||
return parent, nil
|
return parent, nil
|
||||||
}
|
}
|
||||||
@@ -141,9 +181,12 @@ func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.Ex
|
|||||||
|
|
||||||
// Parse the index
|
// Parse the index
|
||||||
var index uint32
|
var index uint32
|
||||||
|
|
||||||
_, err := fmt.Sscanf(component, "%d", &index)
|
_, err := fmt.Sscanf(component, "%d", &index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid path component: %s", component)
|
return nil, fmt.Errorf(
|
||||||
|
"%w: %s", ErrInvalidPathComponent, component,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply hardening if needed
|
// Apply hardening if needed
|
||||||
@@ -164,8 +207,14 @@ func deriveChildKey(parent *hdkeychain.ExtendedKey, path string) (*hdkeychain.Ex
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
// DeriveBIP39Entropy derives entropy for a BIP39 mnemonic
|
||||||
func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, index uint32) ([]byte, error) {
|
func DeriveBIP39Entropy(
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'/%d'", BIP85_MASTER_PATH, AppBIP39, language, words, index)
|
masterKey *hdkeychain.ExtendedKey,
|
||||||
|
language, words, index uint32,
|
||||||
|
) ([]byte, error) {
|
||||||
|
path := fmt.Sprintf(
|
||||||
|
"%s/%d'/%d'/%d'/%d'",
|
||||||
|
BIP85_MASTER_PATH, AppBIP39, language, words, index,
|
||||||
|
)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -183,6 +232,7 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
|
|||||||
)
|
)
|
||||||
|
|
||||||
var bits int
|
var bits int
|
||||||
|
|
||||||
switch words {
|
switch words {
|
||||||
case words12:
|
case words12:
|
||||||
bits = 128
|
bits = 128
|
||||||
@@ -195,7 +245,7 @@ func DeriveBIP39Entropy(masterKey *hdkeychain.ExtendedKey, language, words, inde
|
|||||||
case words24:
|
case words24:
|
||||||
bits = 256
|
bits = 256
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("invalid BIP39 word count: %d", words)
|
return nil, fmt.Errorf("%w: %d", ErrInvalidWordCount, words)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate to the required number of bits (bytes = bits / 8)
|
// Truncate to the required number of bits (bytes = bits / 8)
|
||||||
@@ -218,6 +268,7 @@ func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, erro
|
|||||||
|
|
||||||
// Convert to WIF format
|
// Convert to WIF format
|
||||||
privKey, _ := btcec.PrivKeyFromBytes(keyBytes)
|
privKey, _ := btcec.PrivKeyFromBytes(keyBytes)
|
||||||
|
|
||||||
wif, err := btcutil.NewWIF(privKey, &chaincfg.MainNetParams, true) // compressed=true
|
wif, err := btcutil.NewWIF(privKey, &chaincfg.MainNetParams, true) // compressed=true
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to create WIF: %w", err)
|
return "", fmt.Errorf("failed to create WIF: %w", err)
|
||||||
@@ -227,7 +278,10 @@ func DeriveWIFKey(masterKey *hdkeychain.ExtendedKey, index uint32) (string, erro
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeriveXPRV derives an extended private key (XPRV)
|
// DeriveXPRV derives an extended private key (XPRV)
|
||||||
func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.ExtendedKey, error) {
|
func DeriveXPRV(
|
||||||
|
masterKey *hdkeychain.ExtendedKey,
|
||||||
|
index uint32,
|
||||||
|
) (*hdkeychain.ExtendedKey, error) {
|
||||||
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppXPRV, index)
|
path := fmt.Sprintf("%s/%d'/%d'", BIP85_MASTER_PATH, AppXPRV, index)
|
||||||
|
|
||||||
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
entropy, err := DeriveBIP85Entropy(masterKey, path)
|
||||||
@@ -266,10 +320,10 @@ func DeriveXPRV(masterKey *hdkeychain.ExtendedKey, index uint32) (*hdkeychain.Ex
|
|||||||
checksum := doubleSHA256(serializedBytes)[:4]
|
checksum := doubleSHA256(serializedBytes)[:4]
|
||||||
|
|
||||||
// Append checksum
|
// Append checksum
|
||||||
serializedWithChecksum := append(serializedBytes, checksum...)
|
serializedBytes = append(serializedBytes, checksum...)
|
||||||
|
|
||||||
// Base58 encode
|
// Base58 encode
|
||||||
xprvStr := base58.Encode(serializedWithChecksum)
|
xprvStr := base58.Encode(serializedBytes)
|
||||||
|
|
||||||
// Parse the serialized xprv back to an ExtendedKey
|
// Parse the serialized xprv back to an ExtendedKey
|
||||||
return hdkeychain.NewKeyFromString(xprvStr)
|
return hdkeychain.NewKeyFromString(xprvStr)
|
||||||
@@ -284,9 +338,12 @@ func doubleSHA256(data []byte) []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeriveHex derives a raw hex string of specified length
|
// DeriveHex derives a raw hex string of specified length
|
||||||
func DeriveHex(masterKey *hdkeychain.ExtendedKey, numBytes, index uint32) (string, error) {
|
func DeriveHex(
|
||||||
|
masterKey *hdkeychain.ExtendedKey,
|
||||||
|
numBytes, index uint32,
|
||||||
|
) (string, error) {
|
||||||
if numBytes < 16 || numBytes > 64 {
|
if numBytes < 16 || numBytes > 64 {
|
||||||
return "", fmt.Errorf("numBytes must be between 16 and 64")
|
return "", ErrInvalidNumBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_HEX, numBytes, index)
|
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_HEX, numBytes, index)
|
||||||
@@ -303,9 +360,12 @@ func DeriveHex(masterKey *hdkeychain.ExtendedKey, numBytes, index uint32) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBase64Password derives a password encoded in Base64
|
// DeriveBase64Password derives a password encoded in Base64
|
||||||
func DeriveBase64Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint32) (string, error) {
|
func DeriveBase64Password(
|
||||||
|
masterKey *hdkeychain.ExtendedKey,
|
||||||
|
pwdLen, index uint32,
|
||||||
|
) (string, error) {
|
||||||
if pwdLen < 20 || pwdLen > 86 {
|
if pwdLen < 20 || pwdLen > 86 {
|
||||||
return "", fmt.Errorf("pwdLen must be between 20 and 86")
|
return "", ErrInvalidBase64PwdLen
|
||||||
}
|
}
|
||||||
|
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_PWD64, pwdLen, index)
|
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, APP_PWD64, pwdLen, index)
|
||||||
@@ -323,16 +383,22 @@ func DeriveBase64Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
|||||||
|
|
||||||
// Slice to the desired password length
|
// Slice to the desired password length
|
||||||
if len(encodedStr) < int(pwdLen) {
|
if len(encodedStr) < int(pwdLen) {
|
||||||
return "", fmt.Errorf("derived password length %d is shorter than requested length %d", len(encodedStr), pwdLen)
|
return "", fmt.Errorf(
|
||||||
|
"derived password length %d %w %d",
|
||||||
|
len(encodedStr), ErrPasswordTooShort, pwdLen,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return encodedStr[:pwdLen], nil
|
return encodedStr[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeriveBase85Password derives a password encoded in Base85
|
// DeriveBase85Password derives a password encoded in Base85
|
||||||
func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint32) (string, error) {
|
func DeriveBase85Password(
|
||||||
|
masterKey *hdkeychain.ExtendedKey,
|
||||||
|
pwdLen, index uint32,
|
||||||
|
) (string, error) {
|
||||||
if pwdLen < 10 || pwdLen > 80 {
|
if pwdLen < 10 || pwdLen > 80 {
|
||||||
return "", fmt.Errorf("pwdLen must be between 10 and 80")
|
return "", ErrInvalidBase85PwdLen
|
||||||
}
|
}
|
||||||
|
|
||||||
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, AppPWD85, pwdLen, index)
|
path := fmt.Sprintf("%s/%d'/%d'/%d'", BIP85_MASTER_PATH, AppPWD85, pwdLen, index)
|
||||||
@@ -347,16 +413,21 @@ func DeriveBase85Password(masterKey *hdkeychain.ExtendedKey, pwdLen, index uint3
|
|||||||
|
|
||||||
// Slice to the desired password length
|
// Slice to the desired password length
|
||||||
if len(encoded) < int(pwdLen) {
|
if len(encoded) < int(pwdLen) {
|
||||||
return "", fmt.Errorf("encoded length %d is less than requested length %d", len(encoded), pwdLen)
|
return "", fmt.Errorf(
|
||||||
|
"encoded length %d %w %d",
|
||||||
|
len(encoded), ErrEncodedTooShort, pwdLen,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return encoded[:pwdLen], nil
|
return encoded[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// encodeBase85WithRFC1924Charset encodes data using Base85 with the RFC1924 character set
|
// encodeBase85WithRFC1924Charset encodes data using Base85 with the
|
||||||
|
// RFC1924 character set
|
||||||
func encodeBase85WithRFC1924Charset(data []byte) string {
|
func encodeBase85WithRFC1924Charset(data []byte) string {
|
||||||
// RFC1924 character set
|
// RFC1924 character set
|
||||||
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
charset := "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" +
|
||||||
|
"abcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
base85ChunkSize = 4 // Process 4 bytes at a time
|
base85ChunkSize = 4 // Process 4 bytes at a time
|
||||||
@@ -369,7 +440,9 @@ func encodeBase85WithRFC1924Charset(data []byte) string {
|
|||||||
copy(padded, data)
|
copy(padded, data)
|
||||||
|
|
||||||
var buf strings.Builder
|
var buf strings.Builder
|
||||||
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize) // Each 4 bytes becomes 5 Base85 characters
|
|
||||||
|
// Each 4 bytes becomes 5 Base85 characters
|
||||||
|
buf.Grow(len(padded) * base85DigitCount / base85ChunkSize)
|
||||||
|
|
||||||
// Process in 4-byte chunks
|
// Process in 4-byte chunks
|
||||||
for i := 0; i < len(padded); i += base85ChunkSize {
|
for i := 0; i < len(padded); i += base85ChunkSize {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user