Compare commits

35 Commits

Author SHA1 Message Date
41cea400a7 Update golangci-lint to v2.12.2 with canonical config (#29)
All checks were successful
check / check (push) Successful in 43s
Bumps golangci-lint from v2.1.6 (digest-only pin in the `Dockerfile` lint stage) to v2.12.2, pinned by tag and digest (Debian-based image).

Replaces `.golangci.yml` with the canonical strict config: all linters enabled except the standard disable list (`exhaustruct`, `depguard`, `godot`, `wsl`, `wrapcheck`, `varnamelen`), `lll` at 88, `funlen` 80/50, `cyclop` 15, `dupl` 100, and test files are now linted (the old config had `tests: false`, an enable-only list of ~20 linters, `lll` 120, and a blanket exclusion of `internal/macse`).

The stricter config surfaced ~1550 findings, all fixed:

- `wsl_v5` (439) / `nlreturn` (24): blank-line insertions
- `lll` (309): line wrapping at 88 columns; long literals split with `+` concatenation, values unchanged
- `noinlineerr` (130): `if err := ...` split into assignment plus check
- `paralleltest` (116): `t.Parallel()` added to tests without shared state; reasoned `//nolint` where `t.Setenv` or shared fixtures forbid it
- `err113` (97): package-level sentinel errors (new `internal/vault/errors.go`), `%w` wrapping, `errors.Is`
- `perfsprint` (74) / `modernize` (39) / `intrange`: `strconv`, `errors.New`, `slices.Contains`, `any`, `SplitSeq`
- `goconst` (40) / `dupword` (41) / `testifylint` (42) / `thelper` (33): constants, assertion fixes, `t.Helper()`
- `noctx` (22): `exec.CommandContext` for gpg/CLI invocations
- `testpackage` (18): black-box tests moved to `_test` packages where they use only exported identifiers; white-box files carry a reasoned `//nolint`
- `funlen`/`cyclop`/`gocognit`/`nestif`/`dupl`: behavior-preserving helper extraction
- assorted singletons: `gosec`, `gosmopolitan`, `funcorder`, `nonamedreturns`, `makezero`, `prealloc`, `godox`, `nolintlint`, `ireturn`, `nilnil`, `gochecknoinits`

## User-visible strings

**None changed.** Every error message this branch composes is byte-identical to the one `main` composes.

The `err113` sentinels are shaped so `fmt.Errorf` reassembles the original text around them: the sentinel carries the fixed words and the caller supplies the interpolated value in the position it has always occupied. Where the value sits mid-sentence the sentinel holds only a fragment (e.g. `vault.ErrVaultNotFound` is `"does not exist"`, composed by its caller as `vault <name> does not exist`); each such sentinel documents the message it participates in.

Verified mechanically, not by inspection: every `fmt.Errorf` and `errors.New` call site in both trees is parsed, the `Error()` text of any sentinel passed to `%w` is substituted in, and the resulting sets of composed message templates are compared. All 350 templates `main` produces are still produced, character for character. The set of lost or altered messages is empty.

## `unlocker list`

`findUnlockerIDByMetadata` returns `(string, error)` rather than signalling failure with an empty ID, so an unreadable `unlockers.d` is no longer indistinguishable from "no matching entry". `UnlockersList` skips such an entry with a warning naming the directory — its behavior before the scan was extracted into a helper — instead of emitting a row under a synthesized fallback ID that no `unlocker remove` or `unlocker select` can match and that suppresses the current-unlocker marker. The duplicate-check and shell-completion callers skip on the same condition, matching their pre-extraction behavior. Covered by `internal/cli/unlockers_list_test.go`.

`TODO.md` records the change plus follow-ups (version-completion TODOs formerly in code comments, darwin-gated files exceeding 88 columns that Linux CI does not lint).

`make check` is green and the pinned v2.12.2 image reports `0 issues.` Note the test suite needs the memlock ulimit from `script/cibuild` for the 10MB memguard test; that requirement is pre-existing.

Not changed: `script/bootstrap` installs golangci-lint via the system package manager (no version pin to bump), and `script/lint` invokes whatever `golangci-lint` is on PATH. golangci-lint v2.12 deprecates `gomodguard` in favor of `gomodguard_v2` (warning only); the canonical config owns that decision.

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #29
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-10 15:23:33 +02:00
6e5e0db999 Add .editorconfig (closes #27) (#28)
All checks were successful
check / check (push) Successful in 42s
Adds `.editorconfig`, copied verbatim from `sneak/dnswatcher` (`root = true`; space/4/lf/utf-8/trim/final-newline, with `[Makefile]` overriding to tab). secret had none. Resolves #27.

`make check` is green on the host (`0 issues`, all packages pass). Note: `docker build .` fails only on a pre-existing, environmental `memguard` `mlock` / `RLIMIT_MEMLOCK` issue in the 10MB-secret test — it reproduces identically on pristine `origin/main` with this file removed, and secret's real CI sets the memlock ulimit for it. A static config file cannot affect Go tests, so this change is clean; the container ulimit is a separate pre-existing concern.

Left open for review (not merged).

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #28
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 17:46:27 +02:00
e0e5ae68a4 scripts-to-rule-them-all (#26)
All checks were successful
check / check (push) Successful in 38s
Reviewed-on: #26
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:13:44 +02:00
1fc11529ed TODO (#25)
All checks were successful
check / check (push) Successful in 40s
Reviewed-on: #25
2026-07-06 21:20:29 +02:00
b090b3f86b ci: add Gitea Actions workflow for make check (#21)
All checks were successful
check / check (push) Successful in 26s
Adds CI workflow that runs `make check` on push/PR to main.

Co-authored-by: user <user@Mac.lan guest wan>
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #21
Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
Co-committed-by: clawbot <sneak+clawbot@sneak.cloud>
2026-03-30 21:34:49 +02:00
a3d3fb3b69 secure-enclave-unlocker (#24)
Co-authored-by: clawbot <clawbot@eeqj.de>
Reviewed-on: #24
Reviewed-by: clawbot <clawbot@noreply.example.org>
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-03-14 07:36:28 +01:00
4dc26c9394 Merge pull request 'chore: remove stale .cursorrules and coverage.out' (#22) from chore/remove-stale-files into main
Reviewed-on: #22
2026-02-28 19:29:52 +01:00
user
7546cb094f chore: remove stale .cursorrules and coverage.out
Remove committed editor config (.cursorrules) and test coverage
artifact (coverage.out). Both added to .gitignore.
2026-02-20 02:59:23 -08:00
797d2678c8 Merge pull request 'Add secret.Warn() calls for all silent anomalous conditions' (#20) from clawbot/secret:audit/add-warnings into main
Reviewed-on: #20
2026-02-20 09:22:29 +01:00
user
78015afb35 Add secret.Warn() calls for all silent anomalous conditions
Audit of the codebase found 9 locations where errors or anomalous
conditions were silently swallowed or only logged via Debug(). Users
should be informed when something unexpected happens, even if the
program can continue.

Changes:
- DetermineStateDir: warn on config dir fallback to ~/.config
- info_helper: warn when vault/secret stats cannot be read
- unlockers list: warn on metadata read/parse failures (fixes FIXMEs)
- unlockers list: warn on fallback ID generation
- checkUnlockerExists: warn on errors during duplicate checking
- completions: warn on unlocker metadata read/parse failures
- version list: upgrade metadata load failure from Debug to Warn
- secrets: upgrade file close failure from Debug to Warn
- version naming: warn on malformed version directory names

Closes #19
2026-02-20 00:03:49 -08:00
1c330c697f Merge pull request 'Skip unlocker directories with missing metadata instead of failing (closes #1)' (#17) from clawbot/secret:fix/issue-1 into main
Reviewed-on: #17
2026-02-20 08:59:04 +01:00
d18e286377 Merge branch 'main' into fix/issue-1 2026-02-20 08:58:43 +01:00
f49fde3a06 Merge pull request 'Fix getLongTermPrivateKey derivation index hardcoded to 0 (closes #3)' (#8) from clawbot/secret:fix/issue-3 into main
Reviewed-on: #8
2026-02-20 08:58:21 +01:00
206651f89a Merge branch 'main' into fix/issue-3 2026-02-20 08:58:10 +01:00
user
c0f221b1ca Change missing metadata log from Debug to Warn for visibility without --verbose
Per review feedback: missing unlocker metadata should produce a warning
visible in normal output, not hidden behind debug flags.
2026-02-19 23:57:39 -08:00
09be20a044 Merge pull request 'Allow uppercase letters in secret names (closes #2)' (#16) from clawbot/secret:fix/issue-2 into main
Reviewed-on: #16
2026-02-20 08:57:19 +01:00
2e1ba7d2e0 Merge branch 'main' into fix/issue-2 2026-02-20 08:57:03 +01:00
1a23016df1 Merge pull request 'Validate secret name in GetSecretVersion to prevent path traversal (closes #13)' (#15) from clawbot/secret:fix/issue-13 into main
Reviewed-on: #15
2026-02-20 08:56:51 +01:00
ebe3c17618 Merge branch 'main' into fix/issue-13 2026-02-20 08:56:36 +01:00
clawbot
1a96360f6a Skip unlocker directories with missing metadata instead of failing
When an unlocker directory exists but is missing unlocker-metadata.json,
log a debug warning and skip it instead of returning a hard error that
crashes the entire 'unlocker ls' command.

Closes #1
2026-02-19 23:56:08 -08:00
4f5d2126d6 Merge pull request 'Return error from GetDefaultStateDir when home directory unavailable (closes #14)' (#18) from clawbot/secret:fix/issue-14 into main
Reviewed-on: #18
2026-02-20 08:54:22 +01:00
clawbot
6be4601763 refactor: return errors from NewCLIInstance instead of panicking
Change NewCLIInstance() and NewCLIInstanceWithFs() to return
(*Instance, error) instead of panicking on DetermineStateDir failure.

Callers in RunE contexts propagate the error. Callers in command
construction (for shell completion) use log.Fatalf. Test callers
use t.Fatalf.

Addresses review feedback on PR #18.
2026-02-19 23:53:35 -08:00
user
36ece2fca7 docs: add Go coding policies to AGENTS.md per review request 2026-02-19 23:53:23 -08:00
clawbot
dc225bd0b1 fix: add blank line before return for nlreturn linter 2026-02-19 23:44:38 -08:00
clawbot
6acd57d0ec fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:43:32 -08:00
clawbot
596027f210 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:43:13 -08:00
clawbot
0aa9a52497 test: add test for getLongTermPrivateKey derivation index
Verifies that getLongTermPrivateKey reads the derivation index from
vault metadata instead of using hardcoded index 0. Test creates a
mock vault with DerivationIndex=5 and confirms the derived key
matches index 5.
2026-02-19 23:43:13 -08:00
clawbot
09ec79c57e fix: use vault derivation index in getLongTermPrivateKey instead of hardcoded 0
Previously, getLongTermPrivateKey() always used derivation index 0 when
deriving the long-term key from a mnemonic. This caused wrong key
derivation for vaults with index > 0 (second+ vault from same mnemonic),
leading to silent data corruption in keychain unlocker creation.

Now reads the vault's actual DerivationIndex from vault-metadata.json.
2026-02-19 23:43:13 -08:00
clawbot
e8339f4d12 fix: update integration test to allow uppercase secret names 2026-02-19 23:42:39 -08:00
clawbot
4f984cd9c6 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:41:43 -08:00
clawbot
d1caf0a208 fix: suppress gosec G204 for validated GPG key ID inputs 2026-02-19 23:40:21 -08:00
user
8eb25b98fd fix: block .. path components in secret names and validate in GetSecretObject
- isValidSecretName() now rejects names with '..' path components (e.g. foo/../bar)
- GetSecretObject() now calls isValidSecretName() before building paths
- Added test cases for mid-path traversal patterns
2026-02-15 14:17:33 -08:00
clawbot
6211b8e768 Return error from GetDefaultStateDir when home directory unavailable
When os.UserConfigDir() fails, DetermineStateDir falls back to
os.UserHomeDir(). Previously the error from UserHomeDir was discarded,
which could result in a dangerous root-relative path (/.config/...) if
both calls fail.

Now DetermineStateDir returns (string, error) and propagates failures
from both UserConfigDir and UserHomeDir.

Closes #14
2026-02-15 14:05:15 -08:00
user
0307f23024 Allow uppercase letters in secret names (closes #2)
The isValidSecretName() regex only allowed lowercase letters [a-z], rejecting
valid secret names containing uppercase characters (e.g. AWS access key IDs).

Changed regex from ^[a-z0-9\.\-\_\/]+$ to ^[a-zA-Z0-9\.\-\_\/]+$ and added
tests for uppercase secret names in both vault and secret packages.
2026-02-15 14:03:50 -08:00
clawbot
3fd30bb9e6 Validate secret name in GetSecretVersion to prevent path traversal
Add isValidSecretName() check at the top of GetSecretVersion(), matching
the existing validation in AddSecret(). Without this, crafted secret names
containing path traversal sequences (e.g. '../../../etc/passwd') could be
used to read files outside the vault directory.

Add regression tests for both GetSecretVersion and GetSecret.

Closes #13
2026-02-15 14:03:28 -08:00
93 changed files with 9797 additions and 5211 deletions

View File

@@ -1,3 +0,0 @@
EXTREMELY IMPORTANT: Read and follow the policies, procedures, and
instructions in the `AGENTS.md` file in the root of the repository. Make
sure you follow *all* of the instructions meticulously.

View File

@@ -17,5 +17,4 @@ coverage.out
.claude/ .claude/
# Local settings # Local settings
.golangci.yml
.claude/settings.local.json .claude/settings.local.json

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

View File

@@ -0,0 +1,9 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-28
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild

4
.gitignore vendored
View File

@@ -6,3 +6,7 @@ cli.test
vault.test vault.test
*.test *.test
settings.local.json settings.local.json
# Stale files
.cursorrules
coverage.out

View File

@@ -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
- gochecknoglobals # Check that no global variables exist
# Default/existing linters that are commonly useful
- govet
- errcheck
- staticcheck
- unused
- ineffassign
- misspell
- revive
- gosec
- unconvert
- unparam
linters-settings:
lll: lll:
line-length: 120 line-length: 88
funlen:
mnd: lines: 80
# List of enabled checks, see https://github.com/tommy-muehle/go-mnd/#checks for description. statements: 50
checks: cyclop:
- argument max-complexity: 15
- case dupl:
- condition threshold: 100
- 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

View File

@@ -141,3 +141,17 @@ Version: 2025-06-08
- Local application imports - Local application imports
Each group should be separated by a blank line. Each group should be separated by a blank line.
## Go-Specific Guidelines
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
6. **Pin external dependencies by commit hash**, not mutable tags.

View File

@@ -1,50 +1,46 @@
# Build stage # Lint stage — fast feedback on formatting and lint issues
FROM golang:1.24-alpine AS builder # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
# Install build dependencies WORKDIR /src
RUN apk add --no-cache \
gcc \
musl-dev \
make \
git
# Set working directory
WORKDIR /build
# Copy go mod files
COPY go.mod go.sum ./ COPY go.mod go.sum ./
# Download dependencies
RUN go mod download RUN go mod download
# Copy source code
COPY . . COPY . .
# Build the binary RUN make fmt-check
RUN CGO_ENABLED=1 go build -v -o secret cmd/secret/main.go RUN make lint
# Build stage — tests and compilation
# golang 1.24.13-alpine (2026-03-10)
FROM golang@sha256:8bee1901f1e530bfb4a7850aa7a479d17ae3a18beb6e09064ed54cfd245b7191 AS builder
# Force BuildKit to run the lint stage
COPY --from=lint /src/go.sum /dev/null
RUN apk add --no-cache gcc musl-dev make git gnupg
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
RUN make build
# Runtime stage # Runtime stage
FROM alpine:latest # alpine 3.23 (2026-03-10)
FROM alpine@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
# Install runtime dependencies RUN apk add --no-cache ca-certificates gnupg
RUN apk add --no-cache \
ca-certificates \
gnupg
# Create non-root user
RUN adduser -D -s /bin/sh secret RUN adduser -D -s /bin/sh secret
# Copy binary from builder
COPY --from=builder /build/secret /usr/local/bin/secret COPY --from=builder /build/secret /usr/local/bin/secret
# Ensure binary is executable
RUN chmod +x /usr/local/bin/secret RUN chmod +x /usr/local/bin/secret
# Switch to non-root user
USER secret USER secret
# Set working directory
WORKDIR /home/secret WORKDIR /home/secret
# Set entrypoint
ENTRYPOINT ["secret"] ENTRYPOINT ["secret"]

View File

@@ -7,8 +7,16 @@ GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \ LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)' -X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks vet
default: check default: check
bootstrap:
@script/bootstrap
setup:
@script/setup
build: ./secret build: ./secret
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.* ./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
@@ -17,24 +25,25 @@ build: ./secret
vet: vet:
go vet ./... go vet ./...
test: lint vet test:
go test ./... || go test -v ./... @script/test
fmt: fmt:
go fmt ./... @script/fmt
lint: lint:
golangci-lint run --timeout 5m @script/lint
check: build test check:
@script/check
# Build Docker container # Build Docker container
docker: docker:
docker build -t sneak/secret . @script/docker
# Run Docker container interactively # Run Docker container interactively
docker-run: docker-run:
docker run --rm -it sneak/secret docker run --rm -it "$$(./script/projectname)"
# Clean build artifacts # Clean build artifacts
clean: clean:
@@ -42,3 +51,9 @@ clean:
install: ./secret install: ./secret
cp ./secret $(HOME)/bin/secret cp ./secret $(HOME)/bin/secret
fmt-check:
@script/fmt-check
hooks:
@script/install-precommit

View File

@@ -184,6 +184,7 @@ Creates a new unlocker of the specified type:
- `passphrase`: Traditional passphrase-protected unlocker - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only) - `keychain`: macOS Keychain integration (macOS only)
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified) - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
@@ -286,11 +287,11 @@ Unlockers provide different authentication methods to access the long-term keys:
- Automatic unlocking when Keychain is unlocked - Automatic unlocking when Keychain is unlocked
- Cross-application integration - Cross-application integration
4. **Secure Enclave Unlockers** (macOS - planned): 4. **Secure Enclave Unlockers** (macOS):
- Hardware-backed key storage using Apple Secure Enclave - Hardware-backed key storage using Apple Secure Enclave
- Currently partially implemented but non-functional - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
- Requires Apple Developer Program membership and code signing entitlements - ECIES encryption: vault long-term key encrypted directly by SE hardware
- Full implementation blocked by entitlement requirements - Protected by biometric authentication (Touch ID) or system password
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets. Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
@@ -330,8 +331,7 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
- Hardware token support via PGP/GPG integration - Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security - macOS Keychain integration for system-level security
- Secure Enclave support planned (requires paid Apple Developer Program for - Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
signed entitlements to access the SEP and doxxing myself to Apple)
## Examples ## Examples
@@ -385,6 +385,7 @@ secret vault remove personal --force
secret unlocker add passphrase # Password-based secret unlocker add passphrase # Password-based
secret unlocker add pgp --keyid ABCD1234 # GPG key secret unlocker add pgp --keyid ABCD1234 # GPG key
secret unlocker add keychain # macOS Keychain (macOS only) secret unlocker add keychain # macOS Keychain (macOS only)
secret unlocker add secure-enclave # macOS Secure Enclave (macOS only)
# List unlockers # List unlockers
secret unlocker list secret unlocker list
@@ -443,7 +444,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
### Cross-Platform Support ### Cross-Platform Support
- **macOS**: Full support including Keychain and planned Secure Enclave integration - **macOS**: Full support including Keychain and Secure Enclave integration
- **Linux**: Full support (excluding macOS-specific features) - **Linux**: Full support (excluding macOS-specific features)
## Security Considerations ## Security Considerations
@@ -485,9 +486,39 @@ go test ./... # Unit tests
go test -tags=integration -v ./internal/cli # Integration tests go test -tags=integration -v ./internal/cli # Integration tests
``` ```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call
them. We provide:
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
module download), idempotently
- `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by
other scripts such as `script/docker`
- `script/test` — run `go vet` and the test suite (verbose rerun on
failure)
- `script/lint` — run `golangci-lint`
- `script/fmt` — format all Go code (writes)
- `script/fmt-check` — check formatting without writing
- `script/check` — run `script/test`, `script/lint`, and
`script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: `docker build --ulimit
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
checks)
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
then `script/check`
- `script/install-precommit` — install the git pre-commit hook that
runs `script/precommit`
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, and macOS Keychain unlockers - **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases

408
REPO_POLICIES.md Normal file
View File

@@ -0,0 +1,408 @@
---
title: Repository Policies
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
style conventions are in separate documents:
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
(general, bash, Docker)
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
---
- Cross-project documentation (such as this file) must include
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
with the authoritative source as policies evolve.
- **ALL external references must be pinned by cryptographic hash.** This
includes Docker base images, Go modules, npm packages, GitHub Actions, and
anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
`:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- Every repo with software must have a root `Makefile` with these targets:
`make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
`make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- Repos follow the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions.
- Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
- Use `yarn`, not `npm`.
- Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables.
- Dockerized web services listen on port 8080 by default, overridable with
`PORT`.
- **HTTP/web services must be hardened for production internet exposure before
tagging 1.0.** This means full compliance with security best practices
including, without limitation, all of the following:
- **Security headers** on every response:
- `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
and `includeSubDomains`.
- `Content-Security-Policy` (CSP) with a restrictive default policy
(`default-src 'self'` as a baseline, tightened per-resource as
needed). Never use `unsafe-inline` or `unsafe-eval` unless
unavoidable, and document the reason.
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
Prefer the `frame-ancestors` CSP directive as the primary control.
- `X-Content-Type-Options: nosniff`.
- `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
- `Permissions-Policy` restricting access to browser features the
application does not use (camera, microphone, geolocation, etc.).
- **Request and response limits:**
- Maximum request body size enforced on all endpoints (e.g. Go
`http.MaxBytesReader`). Choose a sane default per-route; never accept
unbounded input.
- Maximum response body size where applicable (e.g. paginated APIs).
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
against slowloris attacks.
- `WriteTimeout` on the `http.Server`.
- `IdleTimeout` on the `http.Server`.
- Per-handler execution time limits via `context.WithTimeout` or
chi/stdlib `middleware.Timeout`.
- **Authentication and session security:**
- Rate limiting on password-based authentication endpoints. API keys are
high-entropy and not susceptible to brute force, so they are exempt.
- CSRF tokens on all state-mutating HTML forms. API endpoints
authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
This list is non-exhaustive. Apply defense-in-depth: if a standard security
hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- `README.md` is the primary documentation. Required sections:
- **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- First commit of a new repo should contain only `README.md`.
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
committing.
- Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore`
- `.gitea/workflows/check.yml`
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

247
TODO.md
View File

@@ -1,147 +1,120 @@
# TODO for 1.0 Release # Workflow
This document outlines the bugs, issues, and improvements that need to be * branch (from `main`)
addressed before the 1.0 release of the secret manager. Items are * do the work in Next Step
prioritized from most critical (top) to least critical (bottom). * move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
## CRITICAL BLOCKERS FOR 1.0 RELEASE # Status
### Command Injection Vulnerabilities pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in
- [ ] **1. PGP command injection risk**: `internal/secret/pgpunlocker.go:323-327` - GPG key IDs passed directly to exec.Command without proper escaping flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06).
- [ ] **2. Keychain command injection risk**: `internal/secret/keychainunlocker.go:472-476` - data.String() passed to security command without escaping
### Memory Security Critical Issues # Next Step
- [ ] **3. Plain text passphrase in memory**: `internal/secret/keychainunlocker.go:342,393-396` - KeychainData struct stores AgePrivKeyPassphrase as unprotected string
- [ ] **4. Sensitive string conversions**: `internal/secret/keychainunlocker.go:356`, `internal/secret/pgpunlocker.go:256`, `internal/secret/version.go:155` - Age identity .String() creates unprotected copies
### Race Conditions (Data Corruption Risk) Bring the repo into policy compliance in one commit:
- [ ] **5. No file locking mechanism**: `internal/vault/secrets.go:142-176` - Multiple concurrent operations can corrupt vault state
- [ ] **6. Non-atomic file operations**: Various locations - Interrupted writes leave vault inconsistent
### Input Validation Vulnerabilities - Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/
- [ ] **7. Path traversal risk**: `internal/vault/secrets.go:75-99` - Secret names allow dots which could enable traversal attacks with encoding docker already exist).
- [ ] **8. Missing size limits**: `internal/vault/secrets.go:102` - No maximum secret size allows DoS via memory exhaustion - Add REPO_POLICIES.md and .editorconfig.
- Add .gitea/workflows/check.yml running make check.
- Verify Dockerfile base images are pinned by sha256.
### Timing Attack Vulnerabilities # Completed Steps
- [ ] **9. Non-constant-time passphrase comparison**: `internal/cli/init.go:209-216` - bytes.Equal() vulnerable to timing attacks
- [ ] **10. Non-constant-time key validation**: `internal/vault/vault.go:95-100` - Public key comparison leaks timing information
## CRITICAL MEMORY SECURITY ISSUES - 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,
Makefile shims, README Entrypoints section
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
protection, plus review fixes (stub panics, derivation index, tests,
README) on branch secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with
missing metadata, allow uppercase secret names, fix hardcoded
derivation index, validate names in GetSecretVersion against path
traversal, return errors instead of panicking, add Warn() on silent
anomalies.
- Memory security hardening: LockedBuffer used through encrypt/decrypt
paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated
bare-[]byte APIs removed.
- Per-secret keypair architecture, vault package refactor, versioning
with --version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic,
CLI).
### Functions accepting bare []byte for sensitive data # Future Steps
- [x] **1. Secret.Save accepts unprotected data**: `internal/secret/secret.go:67` - `Save(value []byte, force bool)` - ✓ REMOVED - deprecated function deleted
- [x] **2. EncryptWithPassphrase accepts unprotected data**: `internal/secret/crypto.go:73` - `EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer)` - ✓ FIXED - now accepts LockedBuffer for data
- [x] **3. storeInKeychain accepts unprotected data**: `internal/secret/keychainunlocker.go:469` - `storeInKeychain(itemName string, data []byte)` - ✓ FIXED - now accepts LockedBuffer for data
- [x] **4. gpgEncryptDefault accepts unprotected data**: `internal/secret/pgpunlocker.go:351` - `gpgEncryptDefault(data []byte, keyID string)` - ✓ FIXED - now accepts LockedBuffer for data
### Functions returning unprotected secrets - Compliance (after Next Step lands): keep main green under the new
- [x] **5. GetValue returns unprotected secret**: `internal/secret/secret.go:93` - `GetValue(unlocker Unlocker) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer internally .gitea workflow; run make check before every merge.
- [x] **6. DecryptWithIdentity returns unprotected data**: `internal/secret/crypto.go:57` - `DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer - Implement version-number shell completion for the second arg of
- [x] **7. DecryptWithPassphrase returns unprotected data**: `internal/secret/crypto.go:94` - `DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer `secret version promote` and `secret version rm`
- [x] **8. gpgDecryptDefault returns unprotected data**: `internal/secret/pgpunlocker.go:368` - `gpgDecryptDefault(encryptedData []byte) ([]byte, error)` - ✓ FIXED - now returns LockedBuffer (`internal/cli/version.go`; was an in-code TODO removed for godox).
- [x] **9. getSecretValue returns unprotected data**: `internal/cli/crypto.go:269` - `getSecretValue()` returns bare []byte - ✓ ALREADY FIXED - returns LockedBuffer - Cover mnemonic-vs-xprv identity consistency in
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
### Intermediate string variables for passphrases in-code FIXME removed for godox).
- [x] **10. Passphrase extracted to string**: `internal/secret/crypto.go:79,100` - `passphraseStr := passphrase.String()` - ✓ UNAVOIDABLE - age library requires string parameter - Darwin-gated files (`internal/secret/keychainunlocker.go`,
- [ ] **11. Age secret key in plain string**: `internal/cli/crypto.go:86,91,113` - Age secret key stored in plain string variable before conversion back to secure buffer `seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related
tests) are not linted on the Linux CI runner and still contain lines
### Unprotected buffer.Bytes() usage over the new 88-column limit; they will surface if lint ever runs on
- [ ] **12. GPG encrypt exposes private key**: `internal/secret/pgpunlocker.go:256` - `GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), gpgKeyID)` - private key exposed to external function macOS.
- [ ] **13. Keychain encrypt exposes private key**: `internal/secret/keychainunlocker.go:371` - `EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)` - private key passed as bare bytes - Merge secure-enclave-unlocker to main once review is done.
- 1.0 critical security blockers (from repo TODO.md):
## Code Cleanups - Command injection: GPG key IDs passed unescaped to exec.Command
(pgpunlocker.go:323-327); data.String() passed unescaped to the
* we shouldn't be passing around a statedir, it should be read from the security command (keychainunlocker.go:472-476).
environment or default. - Memory security: KeychainData stores AgePrivKeyPassphrase as a
plain string (keychainunlocker.go:342,393-396); age identity
## HIGH PRIORITY SECURITY ISSUES .String() creates unprotected copies (keychainunlocker.go:356,
pgpunlocker.go:256, version.go:155); age secret key held in a
- [ ] **4. Application crashes on corrupted metadata**: Code panics instead plain string in cli/crypto.go:86,91,113; private keys exposed via
of returning errors when metadata is corrupt, causing denial of service. buffer.Bytes() to GPGEncryptFunc and EncryptWithPassphrase.
Found in pgpunlocker.go:116 and keychainunlocker.go:141. - Race conditions: no file locking in vault/secrets.go:142-176;
non-atomic writes can leave the vault inconsistent.
- [ ] **5. Insufficient input validation**: Secret names allow potentially - Input validation: dots in secret names risk path traversal
dangerous patterns including dots that could enable path traversal attacks (vault/secrets.go:75-99); no maximum secret size (DoS).
(vault/secrets.go:70-93). - Timing attacks: bytes.Equal passphrase compare (cli/init.go:
209-216); non-constant-time public key compare (vault.go:95-100).
- [ ] **6. Race conditions in file operations**: Multiple concurrent - High priority:
operations could corrupt the vault state due to lack of file locking - Return errors instead of panicking on corrupted metadata
mechanisms. (pgpunlocker.go:116, keychainunlocker.go:141).
- Secure temporary file handling and cleanup.
- [ ] **7. Insecure temporary file handling**: Temporary files containing - Print cobra usage only for argument errors, not internal
sensitive data may not be properly cleaned up or secured. failures.
- Initialize a default unlock key at vault creation.
## HIGH PRIORITY FUNCTIONALITY ISSUES - Confirmation prompts for destructive operations (keys rm, vault
deletion).
- [ ] **8. Inappropriate Cobra usage printing**: Commands currently print - Add secret rm and vault deletion commands.
usage information for all errors, including internal program failures. - Medium priority:
Usage should only be printed when the user provides incorrect arguments or - Standardize error messages; stop leaking internals.
invalid commands. - Graceful handling of corrupted or missing key files with recovery
suggestions.
- [ ] **9. Missing current unlock key initialization**: When creating - Validate GPG key existence before creating PGP unlock keys.
vaults, no default unlock key is selected, which can cause operations to - Split oversized CLI functions.
fail. - Document env var security (SB_UNLOCK_PASSPHRASE,
SB_SECRET_MNEMONIC); clear after use.
- [ ] **10. Add confirmation prompts for destructive operations**: - mlock/munlock for sensitive allocations.
Operations like `keys rm` and vault deletion should require confirmation. - Cleanups: read statedir from environment or default instead of
passing it around.
- [ ] **11. No secret deletion command**: Missing `secret rm <secret-name>` - Enhancements: help examples, shell completion, colored output,
functionality. --quiet flag, name suggestions on miss, audit logging, hardware
integration tests (Keychain, GPG), naming consistency, vault
- [ ] **12. Missing vault deletion command**: No way to delete vaults that export/import, batch operations, search, secret metadata
are no longer needed. (descriptions, tags).
## MEDIUM PRIORITY ISSUES
- [ ] **13. Inconsistent error messages**: Error messages need
standardization and should be user-friendly. Many errors currently expose
internal implementation details.
- [ ] **14. No graceful handling of corrupted state**: If key files are
corrupted or missing, the tool should provide clear error messages and
recovery suggestions.
- [ ] **15. No validation of GPG key existence**: Should verify the
specified GPG key exists before creating PGP unlock keys.
- [ ] **16. Better separation of concerns**: Some functions in CLI do too
much and should be split.
- [ ] **17. Environment variable security**: Sensitive data read from
environment variables (SB_UNLOCK_PASSPHRASE, SB_SECRET_MNEMONIC) without
proper clearing. Document security implications.
- [ ] **18. No secure memory allocation**: No use of mlock/munlock to
prevent sensitive data from being swapped to disk.
## LOWER PRIORITY ENHANCEMENTS
- [ ] **19. Add `--help` examples**: Command help should include practical examples for each operation.
- [ ] **20. Add shell completion**: Bash/Zsh completion for commands and secret names.
- [ ] **21. Colored output**: Use colors to improve readability of lists and error messages.
- [ ] **22. Add `--quiet` flag**: Option to suppress non-essential output.
- [ ] **23. Smart secret name suggestions**: When a secret name is not found, suggest similar names.
- [ ] **24. Audit logging**: Log all secret access and modifications for security auditing.
- [ ] **25. Integration tests for hardware features**: Automated testing of Keychain and GPG functionality.
- [ ] **26. Consistent naming conventions**: Some variables and functions use inconsistent naming patterns.
- [ ] **27. Export/import functionality**: Add ability to export/import entire vaults, not just individual secrets.
- [ ] **28. Batch operations**: Add commands to process multiple secrets at once.
- [ ] **29. Search functionality**: Add ability to search secret names and potentially contents.
- [ ] **30. Secret metadata**: Add support for descriptions, tags, or other metadata with secrets.
## COMPLETED ITEMS ✓
- [x] **Missing secret history/versioning**: ✓ Implemented - versioning system exists with --version flag support
- [x] **XDG compliance on Linux**: ✓ Implemented - uses os.UserConfigDir() which respects XDG_CONFIG_HOME
- [x] **Consistent interface implementation**: ✓ Implemented - Unlocker interface is well-defined and consistently implemented

View File

@@ -1,102 +0,0 @@
mode: set
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:57.41,60.38 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:60.38,61.41 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:65.2,70.3 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:74.50,76.2 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:79.85,81.28 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:81.28,83.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:86.2,87.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:87.16,89.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:92.2,93.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:93.16,95.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:98.2,98.35 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:102.89,105.16 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:105.16,107.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:110.2,114.21 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:118.99,119.46 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:119.46,121.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:124.2,134.39 5 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:134.39,137.15 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:137.15,140.4 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:143.3,145.17 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:145.17,147.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.3,150.15 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:150.15,152.4 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:155.3,156.17 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:156.17,158.4 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:160.3,160.14 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:163.2,163.17 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:167.107,171.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:171.16,173.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:177.2,186.15 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:187.15,188.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:189.15,190.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:191.15,192.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:193.15,194.13 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:195.15,196.13 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:197.10,198.64 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:202.2,204.21 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:208.84,212.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:212.16,214.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:217.2,222.16 4 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:222.16,224.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:226.2,226.26 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:230.99,234.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:234.16,236.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:239.2,251.45 6 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:251.45,253.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:256.2,275.45 12 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:279.39,284.2 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:287.91,288.36 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:288.36,290.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:292.2,295.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:295.16,297.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:300.2,302.41 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:306.100,307.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:307.32,309.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:311.2,314.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:314.16,316.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:319.2,325.35 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:325.35,327.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:329.2,329.33 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:333.100,334.32 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:334.32,336.3 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:338.2,341.16 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:341.16,343.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:346.2,349.32 2 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:349.32,351.3 1 0
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:353.2,353.30 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:357.57,375.52 7 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:375.52,381.46 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:381.46,385.4 3 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:387.3,387.20 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:390.2,390.21 1 1
git.eeqj.de/sneak/secret/pkg/bip85/bip85.go:394.67,396.2 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:32.22,36.2 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:40.67,41.31 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:41.31,43.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:46.2,55.16 6 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:55.16,57.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:58.2,59.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:59.16,61.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:63.2,63.52 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:68.63,74.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:74.16,76.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:79.2,83.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:83.16,85.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:88.2,91.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:91.16,93.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:95.2,95.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:100.67,103.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:103.16,105.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:108.2,112.16 3 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:112.16,114.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:117.2,120.16 4 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:120.16,122.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:124.2,124.17 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:129.77,131.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:131.16,133.3 1 0
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:135.2,135.33 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:140.81,142.16 2 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:142.16,144.3 1 1
git.eeqj.de/sneak/secret/pkg/agehd/agehd.go:146.2,146.33 1 1

View File

@@ -17,27 +17,36 @@ 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 { func NewCLIInstance() (*Instance, error) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := secret.DetermineStateDir("")
stateDir, err := secret.DetermineStateDir("")
if err != nil {
return nil, fmt.Errorf("cannot determine state directory: %w", err)
}
return &Instance{ return &Instance{
fs: fs, fs: fs,
stateDir: stateDir, stateDir: stateDir,
} }, nil
} }
// NewCLIInstanceWithFs creates a new CLI instance with the given filesystem (for testing) // NewCLIInstanceWithFs creates a new CLI instance with the given
func NewCLIInstanceWithFs(fs afero.Fs) *Instance { // filesystem (for testing)
stateDir := secret.DetermineStateDir("") func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
stateDir, err := secret.DetermineStateDir("")
if err != nil {
return nil, fmt.Errorf("cannot determine state directory: %w", err)
}
return &Instance{ return &Instance{
fs: fs, fs: fs,
stateDir: stateDir, stateDir: stateDir,
} }, 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,
@@ -61,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...)
} }

View File

@@ -1,34 +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 := NewCLIInstanceWithFs(fs)
instance, err := cli.NewCLIInstanceWithFs(fs)
if err != nil {
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")
} }
@@ -41,7 +50,11 @@ func TestDetermineStateDir(t *testing.T) {
testEnvDir := "/test-env-dir" testEnvDir := "/test-env-dir"
t.Setenv(secret.EnvStateDir, testEnvDir) t.Setenv(secret.EnvStateDir, testEnvDir)
stateDir := secret.DetermineStateDir("") stateDir, err := secret.DetermineStateDir("")
if err != nil {
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)
} }
@@ -49,9 +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 = secret.DetermineStateDir(customConfigDir)
stateDir, err = secret.DetermineStateDir(customConfigDir)
if err != nil {
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)
} }
} }

View File

@@ -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])
} }
}, },
} }

View File

@@ -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,73 +72,46 @@ 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, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue continue
} }
for _, file := range files { if id != "" && strings.HasPrefix(id, toComplete) {
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 {
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
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) completions = append(completions, id)
} }
} }
break
}
}
}
return completions, cobra.ShellCompDirectiveNoFileComp return completions, cobra.ShellCompDirectiveNoFileComp
} }
} }
// 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)
@@ -143,23 +122,20 @@ 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 getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func( func completeVaultQualifiedSecrets(
cmd *cobra.Command, args []string, toComplete string, fs afero.Fs, stateDir, toComplete string,
) ([]string, cobra.ShellCompDirective) { ) []string {
return func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var completions []string var completions []string
// Check if we're completing after a vault: prefix
if strings.Contains(toComplete, ":") {
// Complete secret names for the specified vault // Complete secret names for the specified vault
const vaultSecretParts = 2
parts := strings.SplitN(toComplete, ":", vaultSecretParts) parts := strings.SplitN(toComplete, ":", vaultSecretParts)
vaultName := parts[0] vaultName := parts[0]
secretPrefix := parts[1] secretPrefix := parts[1]
vlt := vault.NewVault(fs, stateDir, vaultName) vlt := vault.NewVault(fs, stateDir, vaultName)
secrets, err := vlt.ListSecrets() secrets, err := vlt.ListSecrets()
if err == nil { if err == nil {
for _, secretName := range secrets { for _, secretName := range secrets {
@@ -169,9 +145,16 @@ func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
} }
} }
return completions, cobra.ShellCompDirectiveNoFileComp 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 // Complete vault names with ":" suffix
vaults, err := vault.ListVaults(fs, stateDir) vaults, err := vault.ListVaults(fs, stateDir)
if err == nil { if err == nil {
@@ -183,7 +166,8 @@ func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
} }
// Also complete secrets from current vault (for within-vault moves) // Also complete secrets from current vault (for within-vault moves)
if currentVlt, err := vault.GetCurrentVault(fs, stateDir); err == nil { currentVlt, err := vault.GetCurrentVault(fs, stateDir)
if err == nil {
secrets, err := currentVlt.ListSecrets() secrets, err := currentVlt.ListSecrets()
if err == nil { if err == nil {
for _, secretName := range secrets { for _, secretName := range secrets {
@@ -194,6 +178,25 @@ func getVaultSecretCompletionFunc(fs afero.Fs, stateDir string) func(
} }
} }
return completions, cobra.ShellCompDirectiveNoSpace 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(
cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
return func(
_ *cobra.Command, _ []string, toComplete string,
) ([]string, cobra.ShellCompDirective) {
// Check if we're completing after a vault: prefix
if strings.Contains(toComplete, ":") {
return completeVaultQualifiedSecrets(fs, stateDir, toComplete),
cobra.ShellCompDirectiveNoFileComp
}
return completeUnqualifiedVaultSecrets(fs, stateDir, toComplete),
cobra.ShellCompDirectiveNoSpace
} }
} }

View File

@@ -1,6 +1,7 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -12,20 +13,35 @@ 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")
outputFile, _ := cmd.Flags().GetString("output") outputFile, _ := cmd.Flags().GetString("output")
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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)
}, },
} }
@@ -35,27 +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 := NewCLIInstance()
cli.cmd = cmd
return cli.Decrypt(args[0], inputFile, outputFile)
},
} }
cmd.Flags().StringP("input", "i", "", "Input file (default: stdin)") // resolveEncryptionKey returns a secure buffer holding the age secret key
cmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") // for the named secret, generating and storing a new key if the secret
// does not exist. The caller must destroy the returned buffer.
func (cli *Instance) resolveEncryptionKey(
vlt *vault.Vault, secretName string,
) (*memguard.LockedBuffer, error) {
// Check if secret exists
secretObj := secret.NewSecret(vlt, secretName)
return cmd exists, err := secretObj.Exists()
if err != nil {
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
// 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)
}
// 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
@@ -66,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)
} }
@@ -124,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
} }
@@ -150,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)
} }
@@ -171,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)
} }
@@ -198,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
@@ -209,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
} }
@@ -235,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)
} }

View File

@@ -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",
@@ -38,7 +49,10 @@ func newGenerateMnemonicCmd() *cobra.Command {
`mnemonic phrase that can be used with 'secret init' ` + `mnemonic phrase that can be used with 'secret init' ` +
`or 'secret import'.`, `or 'secret import'.`,
RunE: func(cmd *cobra.Command, _ []string) error { RunE: func(cmd *cobra.Command, _ []string) error {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GenerateMnemonic(cmd) return cli.GenerateMnemonic(cmd)
}, },
@@ -49,21 +63,27 @@ 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 ` +
`store it in the current vault under the given name.`,
Args: cobra.ExactArgs(1), 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")
force, _ := cmd.Flags().GetBool("force") force, _ := cmd.Flags().GetBool("force")
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GenerateSecret(cmd, args[0], length, secretType, force) return cli.GenerateSecret(cmd, args[0], length, secretType, force)
}, },
} }
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
@@ -92,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)")
@@ -110,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":
@@ -122,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 {
@@ -141,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
} }
@@ -164,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)
@@ -178,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()]
} }

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -17,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
) )
@@ -34,20 +35,24 @@ 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
func newInfoCmd() *cobra.Command { func newInfoCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
var jsonOutput bool var jsonOutput bool
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)
}, },
@@ -77,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 {
@@ -88,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
} }
@@ -140,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)

View File

@@ -4,46 +4,64 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// gatherVaultStats collects statistics from all vaults // vaultStats accumulates statistics while walking vault directories
func gatherVaultStats( type vaultStats struct {
fs afero.Fs, totalSecrets int
vaultsDir string, totalSize int64
) (totalSecrets int, totalSize int64, oldestTime, latestTime time.Time, err error) { oldestTime time.Time
vaultEntries, err := afero.ReadDir(fs, vaultsDir) latestTime time.Time
if err != nil {
return 0, 0, time.Time{}, time.Time{}, err
} }
for _, vaultEntry := range vaultEntries { // addVersion accumulates size and timestamp info for one version directory
if !vaultEntry.IsDir() { func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
continue // Add size of encrypted data
dataPath := filepath.Join(versionPath, "data.age")
stat, err := fs.Stat(dataPath)
if err == nil {
s.totalSize += stat.Size()
} }
vaultPath := filepath.Join(vaultsDir, vaultEntry.Name()) // Add size of metadata
secretsPath := filepath.Join(vaultPath, "secrets.d") metaPath := filepath.Join(versionPath, "metadata.age")
// Count secrets in this vault stat, err = fs.Stat(metaPath)
secretEntries, err := afero.ReadDir(fs, secretsPath) if err == nil {
if err != nil { s.totalSize += stat.Size()
continue
} }
for _, secretEntry := range secretEntries { // Track timestamps
if !secretEntry.IsDir() { stat, err = fs.Stat(versionPath)
continue if err == nil {
modTime := stat.ModTime()
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
s.oldestTime = modTime
} }
totalSecrets++ if s.latestTime.IsZero() || modTime.After(s.latestTime) {
secretPath := filepath.Join(secretsPath, secretEntry.Name()) 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 // Get size and timestamps from all versions
versionsPath := filepath.Join(secretPath, "versions") versionsPath := filepath.Join(secretPath, "versions")
versionEntries, err := afero.ReadDir(fs, versionsPath) versionEntries, err := afero.ReadDir(fs, versionsPath)
if err != nil { if err != nil {
continue secret.Warn("Could not read versions directory for secret",
"secret", secretName, "error", err)
return
} }
for _, versionEntry := range versionEntries { for _, versionEntry := range versionEntries {
@@ -51,33 +69,54 @@ func gatherVaultStats(
continue continue
} }
versionPath := filepath.Join(versionsPath, versionEntry.Name()) s.addVersion(fs, 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 // 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(
fs afero.Fs,
vaultsDir string,
) (int, int64, time.Time, time.Time, error) {
vaultEntries, err := afero.ReadDir(fs, vaultsDir)
if err != nil {
return 0, 0, time.Time{}, time.Time{}, err
}
var stats vaultStats
for _, vaultEntry := range vaultEntries {
if !vaultEntry.IsDir() {
continue
}
stats.addVault(fs, vaultsDir, vaultEntry.Name())
}
return stats.totalSecrets, stats.totalSize,
stats.oldestTime, stats.latestTime, nil
} }

View File

@@ -1,12 +1,15 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"os" "os"
"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"
@@ -15,23 +18,91 @@ 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 ` +
`secrets and generate encryption keys.`,
RunE: RunInit, RunE: RunInit,
} }
} }
// RunInit is the exported function that handles the init command // RunInit is the exported function that handles the init command
func RunInit(cmd *cobra.Command, _ []string) error { func RunInit(cmd *cobra.Command, _ []string) error {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
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")
@@ -40,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)
@@ -51,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 envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
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 { if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err) return 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 != "" {
secret.Debug("Using unlock passphrase from environment variable")
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 { if err != nil {
secret.Debug("Failed to read unlock passphrase", "error", err) return 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)
@@ -190,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

View File

@@ -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,

View File

@@ -2,9 +2,12 @@ package cli
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
@@ -19,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
@@ -41,11 +68,17 @@ 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)
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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)
@@ -58,7 +91,11 @@ func newAddCmd() *cobra.Command {
} }
func newGetCmd() *cobra.Command { func newGetCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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",
@@ -66,7 +103,11 @@ 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 := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.GetSecretWithVersion(cmd, args[0], version) return cli.GetSecretWithVersion(cmd, args[0], version)
}, },
@@ -82,7 +123,8 @@ 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 ` +
`by substring match in secret name.`,
Args: cobra.MaximumNArgs(1), 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")
@@ -93,7 +135,10 @@ func newListCmd() *cobra.Command {
filter = args[0] filter = args[0]
} }
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter) return cli.ListSecrets(cmd, jsonOutput, quietOutput, filter)
}, },
@@ -109,13 +154,17 @@ 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 ` +
`vault under the given name.`,
Args: cobra.ExactArgs(1), 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")
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ImportSecret(cmd, args[0], sourceFile, force) return cli.ImportSecret(cmd, args[0], sourceFile, force)
}, },
@@ -129,17 +178,24 @@ func newImportCmd() *cobra.Command {
} }
func newRemoveCmd() *cobra.Command { func newRemoveCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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 {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.RemoveSecret(cmd, args[0], false) return cli.RemoveSecret(cmd, args[0], false)
}, },
@@ -149,7 +205,11 @@ func newRemoveCmd() *cobra.Command {
} }
func newMoveCmd() *cobra.Command { func newMoveCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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"},
@@ -165,29 +225,39 @@ 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 := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.MoveSecret(cmd, args[0], args[1], force) return cli.MoveSecret(cmd, args[0], args[1], force)
}, },
} }
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
@@ -197,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)
// 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
}
var buffers []bufferInfo
defer func() {
for _, b := range buffers { for _, b := range buffers {
b.buffer.Destroy() b.buffer.Destroy()
} }
}() }
// 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
reader := cli.cmd.InOrStdin()
totalSize := 0 totalSize := 0
currentBufferSize := initialSize currentBufferSize := initialBufferSize
sameSize := 0 sameSize := 0
for { for {
@@ -245,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
@@ -255,12 +308,58 @@ 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 {
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) 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 {
@@ -271,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
@@ -302,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
@@ -323,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)
@@ -333,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
@@ -347,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 {
@@ -362,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) {
@@ -372,17 +477,36 @@ 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 {
case jsonOutput:
return printSecretsJSON(cmd, vlt, filteredSecrets, filter)
case quietOutput:
// Quiet output - just secret names
for _, secretName := range filteredSecrets {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
}
return nil
default:
return printSecretsTable(cmd, vlt, filteredSecrets, filter, len(secrets))
}
}
// 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 // For JSON output, get metadata for each secret
secretsWithMetadata := make([]map[string]interface{}, 0, len(filteredSecrets)) secretsWithMetadata := make([]map[string]any, 0, len(filteredSecrets))
for _, secretName := range filteredSecrets { for _, secretName := range filteredSecrets {
secretInfo := map[string]interface{}{ secretInfo := map[string]any{
"name": secretName, "name": secretName,
} }
// Try to get metadata using GetSecretObject // Try to get metadata using GetSecretObject
if secretObj, err := vlt.GetSecretObject(secretName); err == nil { secretObj, err := vlt.GetSecretObject(secretName)
if err == nil {
metadata := secretObj.GetMetadata() metadata := secretObj.GetMetadata()
secretInfo["created_at"] = metadata.CreatedAt secretInfo["created_at"] = metadata.CreatedAt
secretInfo["updated_at"] = metadata.UpdatedAt secretInfo["updated_at"] = metadata.UpdatedAt
@@ -391,7 +515,7 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
secretsWithMetadata = append(secretsWithMetadata, secretInfo) secretsWithMetadata = append(secretsWithMetadata, secretInfo)
} }
output := map[string]interface{}{ output := map[string]any{
"secrets": secretsWithMetadata, "secrets": secretsWithMetadata,
} }
if filter != "" { if filter != "" {
@@ -404,17 +528,23 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
} }
_, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes)) _, _ = fmt.Fprintln(cmd.OutOrStdout(), string(jsonBytes))
} else if quietOutput {
// Quiet output - just secret names return nil
for _, secretName := range filteredSecrets {
_, _ = fmt.Fprintln(cmd.OutOrStdout(), secretName)
} }
} else {
// 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 // Pretty table output
out := cmd.OutOrStdout() out := cmd.OutOrStdout()
if len(filteredSecrets) == 0 { if len(filteredSecrets) == 0 {
if filter != "" { if filter != "" {
_, _ = fmt.Fprintf(out, "No secrets found in vault '%s' matching filter '%s'.\n", vlt.GetName(), filter) _, _ = fmt.Fprintf(out,
"No secrets found in vault '%s' matching filter '%s'.\n",
vlt.GetName(), filter)
} else { } else {
_, _ = fmt.Fprintln(out, "No secrets found in current vault.") _, _ = fmt.Fprintln(out, "No secrets found in current vault.")
_, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.") _, _ = fmt.Fprintln(out, "Run 'secret add <name>' to create one.")
@@ -425,7 +555,8 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
// Get current vault name for display // Get current vault name for display
if filter != "" { if filter != "" {
_, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n", vlt.GetName(), filter) _, _ = fmt.Fprintf(out, "Secrets in vault '%s' matching '%s':\n\n",
vlt.GetName(), filter)
} else { } else {
_, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName()) _, _ = fmt.Fprintf(out, "Secrets in vault '%s':\n\n", vlt.GetName())
} }
@@ -443,29 +574,35 @@ func (cli *Instance) ListSecrets(cmd *cobra.Command, jsonOutput bool, quietOutpu
// Print headers with dynamic width // Print headers with dynamic width
nameFormat := fmt.Sprintf("%%-%ds", maxNameLen) nameFormat := fmt.Sprintf("%%-%ds", maxNameLen)
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED") _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", "NAME", "LAST UPDATED")
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", strings.Repeat("-", len("NAME")), "------------") _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n",
strings.Repeat("-", len("NAME")), "------------")
for _, secretName := range filteredSecrets { for _, secretName := range filteredSecrets {
lastUpdated := "unknown" lastUpdated := "unknown"
if secretObj, err := vlt.GetSecretObject(secretName); err == nil {
secretObj, err := vlt.GetSecretObject(secretName)
if err == nil {
metadata := secretObj.GetMetadata() metadata := secretObj.GetMetadata()
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04") lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
} }
_, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated) _, _ = fmt.Fprintf(out, nameFormat+" %-20s\n", secretName, lastUpdated)
} }
_, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets)) _, _ = fmt.Fprintf(out, "\nTotal: %d secret(s)", len(filteredSecrets))
if filter != "" { if filter != "" {
_, _ = fmt.Fprintf(out, " (filtered from %d)", len(secrets)) _, _ = fmt.Fprintf(out, " (filtered from %d)", totalCount)
} }
_, _ = fmt.Fprintln(out) _, _ = 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 {
@@ -478,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.Debug("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) {
type bufferInfo struct { return errSecretFileTooLarge
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) 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
} }
@@ -572,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)
@@ -606,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 {
if v == dest {
// dest is a vault name, use source secret name // dest is a vault name, use source secret name
destVaultName = dest destVaultName = dest
destSecretName = srcSecretName 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
@@ -642,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)
} }
@@ -650,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
@@ -674,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, "/", "%")
@@ -687,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)
} }
@@ -713,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)
} }
@@ -722,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
@@ -731,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)
} }
@@ -745,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)
@@ -759,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",

View File

@@ -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,73 +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 := NewCLIInstance()
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
@@ -190,70 +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 := NewCLIInstance()
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{
@@ -277,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
@@ -310,17 +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 := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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
@@ -331,55 +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 := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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
@@ -399,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
} }

View File

@@ -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")

View File

@@ -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

View File

@@ -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))

View File

@@ -1,12 +1,16 @@
package cli package cli
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"slices"
"strings" "strings"
"time" "time"
@@ -17,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"`
@@ -36,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)
@@ -51,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, ":")
@@ -70,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 {
@@ -90,13 +127,17 @@ 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 {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
cli.cmd = cmd cli.cmd = cmd
return cli.UnlockersList(jsonOutput) return cli.UnlockersList(jsonOutput)
@@ -108,109 +149,152 @@ 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. supportedTypes = "passphrase, keychain, pgp, secure-enclave"
The passphrase is never stored in plaintext. typeDescriptions = "Available unlocker types:\n" +
"\n" +
pgp - GNU Privacy Guard (GPG) key-based encryption " passphrase - Traditional password-based encryption\n" +
Uses your existing GPG key to encrypt/decrypt the vault's master key. " Prompts for a passphrase that will be " +
Requires gpg to be installed and configured with at least one secret key. "used to encrypt/decrypt the vault's master key.\n" +
Use --keyid to specify a particular key, otherwise uses your default GPG key.` " The passphrase is never stored in " +
"plaintext.\n" +
if runtime.GOOS == "darwin" { "\n" +
supportedTypes = "passphrase, keychain, pgp" " keychain - macOS Keychain integration (macOS only)\n" +
typeDescriptions = `Available unlocker types: " Stores the vault's master key in the " +
"macOS Keychain, protected by your login password.\n" +
passphrase - Traditional password-based encryption " Automatically unlocks when your Keychain " +
Prompts for a passphrase that will be used to encrypt/decrypt the vault's master key. "is unlocked (e.g., after login).\n" +
The passphrase is never stored in plaintext. " Provides seamless integration with macOS " +
"security features like Touch ID.\n" +
keychain - macOS Keychain integration (macOS only) "\n" +
Stores the vault's master key in the macOS Keychain, protected by your login password. " pgp - GNU Privacy Guard (GPG) key-based " +
Automatically unlocks when your Keychain is unlocked (e.g., after login). "encryption\n" +
Provides seamless integration with macOS security features like Touch ID. " Uses your existing GPG key to " +
"encrypt/decrypt the vault's master key.\n" +
pgp - GNU Privacy Guard (GPG) key-based encryption " Requires gpg to be installed and " +
Uses your existing GPG key to encrypt/decrypt the vault's master key. "configured with at least one secret key.\n" +
Requires gpg to be installed and configured with at least one secret key. " Use --keyid to specify a particular key, " +
Use --keyid to specify a particular key, otherwise uses your default GPG 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 {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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
} }
func newUnlockerRemoveCmd() *cobra.Command { func newUnlockerRemoveCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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 := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockersRemove(args[0], force, cmd) return cli.UnlockersRemove(args[0], force, cmd)
}, },
} }
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
} }
func newUnlockerSelectCmd() *cobra.Command { func newUnlockerSelectCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{ return &cobra.Command{
Use: "select <unlocker-id>", Use: "select <unlocker-id>",
@@ -218,13 +302,102 @@ func newUnlockerSelectCmd() *cobra.Command {
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(_ *cobra.Command, args []string) error { RunE: func(_ *cobra.Command, args []string) error {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.UnlockerSelect(args[0]) return cli.UnlockerSelect(args[0])
}, },
} }
} }
// 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
@@ -235,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()
@@ -248,63 +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)
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, 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 {
continue // FIXME this error needs to be handled
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue // FIXME this error needs to be handled
}
// 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)
}
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",
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{
@@ -325,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,
} }
@@ -360,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,
@@ -379,14 +534,40 @@ 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 {
switch unlockerType {
case unlockerTypePassphrase:
return cli.addPassphraseUnlocker(cmd)
case unlockerTypeKeychain:
return cli.addKeychainUnlocker(cmd)
case unlockerTypeSecureEnclave:
return cli.addSecureEnclaveUnlocker(cmd)
case unlockerTypePGP:
return cli.addPGPUnlocker(cmd)
default:
// Build the supported types list based on platform // Build the supported types list based on platform
supportedTypes := "passphrase, pgp" supportedTypes := "passphrase, pgp"
if runtime.GOOS == "darwin" { if runtime.GOOS == platformDarwin {
supportedTypes = "passphrase, keychain, pgp" supportedTypes = "passphrase, keychain, pgp, secure-enclave"
} }
switch unlockerType { return fmt.Errorf("%w: %s (supported: %s)",
case "passphrase": 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 // 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 {
@@ -394,7 +575,8 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
} }
// For passphrase unlockers, we don't need the vault to be unlocked // For passphrase unlockers, we don't need the vault to be unlocked
// The CreatePassphraseUnlocker method will handle getting the long-term key // The CreatePassphraseUnlocker method will handle getting the
// long-term key
// Check if passphrase is set in environment variable // Check if passphrase is set in environment variable
var passphraseBuffer *memguard.LockedBuffer var passphraseBuffer *memguard.LockedBuffer
@@ -417,17 +599,15 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID()) cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// Auto-select the newly created unlocker // Auto-select the newly created unlocker
if err := vlt.SelectUnlocker(passphraseUnlocker.GetID()); err != nil { autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil return nil
}
case "keychain": // addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
if runtime.GOOS != "darwin" { func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
return fmt.Errorf("keychain unlockers are only supported on macOS") if runtime.GOOS != platformDarwin {
return errKeychainMacOSOnly
} }
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir) keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
@@ -436,7 +616,9 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
} }
cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID()) cmd.Printf("Created macOS Keychain unlocker: %s\n", keychainUnlocker.GetID())
if keyName, err := keychainUnlocker.GetKeychainItemName(); err == nil {
keyName, err := keychainUnlocker.GetKeychainItemName()
if err == nil {
cmd.Printf("Keychain Item Name: %s\n", keyName) cmd.Printf("Keychain Item Name: %s\n", keyName)
} }
@@ -445,15 +627,38 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
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)
} }
if err := vlt.SelectUnlocker(keychainUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err) autoSelectUnlocker(cmd, vlt, keychainUnlocker.GetID())
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil return nil
}
case "pgp": // 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 // Get GPG key ID from flag, environment, or default key
var gpgKeyID string var gpgKeyID string
if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" { if flagKeyID, _ := cmd.Flags().GetString("keyid"); flagKeyID != "" {
@@ -466,6 +671,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
if err != nil { if err != nil {
return fmt.Errorf("no GPG key specified and no default key found: %w", err) return fmt.Errorf("no GPG key specified and no default key found: %w", err)
} }
gpgKeyID = defaultKeyID gpgKeyID = defaultKeyID
cmd.Printf("Using default GPG key: %s\n", gpgKeyID) cmd.Printf("Using default GPG key: %s\n", gpgKeyID)
} }
@@ -483,9 +689,11 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
} }
// Check if this GPG key is already added // Check if this GPG key is already added
expectedID := fmt.Sprintf("pgp-%s", fingerprint) expectedID := "pgp-" + fingerprint
if err := cli.checkUnlockerExists(vlt, expectedID); err != nil {
return fmt.Errorf("GPG key %s is already added as an unlocker", gpgKeyID) 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) pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
@@ -497,21 +705,15 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
cmd.Printf("GPG Key ID: %s\n", gpgKeyID) cmd.Printf("GPG Key ID: %s\n", gpgKeyID)
// Auto-select the newly created unlocker // Auto-select the newly created unlocker
if err := vlt.SelectUnlocker(pgpUnlocker.GetID()); err != nil { autoSelectUnlocker(cmd, vlt, pgpUnlocker.GetID())
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil return nil
default:
return fmt.Errorf("unsupported unlocker type: %s (supported: %s)", unlockerType, supportedTypes)
}
} }
// 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 {
@@ -533,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
} }
@@ -571,61 +777,37 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
// Get the list of unlockers and check if any match the ID // Get the list of unlockers and check if any match the ID
unlockers, err := vlt.ListUnlockers() unlockers, err := vlt.ListUnlockers()
if err != nil { if err != nil {
secret.Warn("Could not list unlockers during duplicate check", "error", err)
return nil // If we can't list unlockers, assume it doesn't exist return nil // If we can't list unlockers, assume it doesn't exist
} }
// 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)
return nil return nil
} }
// Check each unlocker's ID // Check each unlocker's ID
for _, metadata := range unlockers {
// Construct the unlocker based on type to get its ID
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir)
for _, metadata := range unlockers {
// Construct the unlocker matching this metadata to get its ID
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
if err != nil { if err != nil {
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 {
continue
}
var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
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)
}
if unlocker != nil && unlocker.GetID() == unlockerID {
return fmt.Errorf("unlocker already exists")
}
break
}
} }
} }

View 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)
}

View File

@@ -2,9 +2,12 @@ package cli
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"time" "time"
@@ -17,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",
@@ -35,13 +54,16 @@ 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 {
jsonOutput, _ := cmd.Flags().GetBool("json") jsonOutput, _ := cmd.Flags().GetBool("json")
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.ListVaults(cmd, jsonOutput) return cli.ListVaults(cmd, jsonOutput)
}, },
@@ -58,7 +80,10 @@ func newVaultCreateCmd() *cobra.Command {
Short: "Create a new vault", Short: "Create a new vault",
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.CreateVault(cmd, args[0]) return cli.CreateVault(cmd, args[0])
}, },
@@ -66,7 +91,10 @@ func newVaultCreateCmd() *cobra.Command {
} }
func newVaultSelectCmd() *cobra.Command { func newVaultSelectCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return &cobra.Command{ return &cobra.Command{
Use: "select <name>", Use: "select <name>",
@@ -74,7 +102,10 @@ func newVaultSelectCmd() *cobra.Command {
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 {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.SelectVault(cmd, args[0]) return cli.SelectVault(cmd, args[0])
}, },
@@ -82,12 +113,16 @@ func newVaultSelectCmd() *cobra.Command {
} }
func newVaultImportCmd() *cobra.Command { func newVaultImportCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
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 {
@@ -96,7 +131,10 @@ func newVaultImportCmd() *cobra.Command {
vaultName = args[0] vaultName = args[0]
} }
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.VaultImport(cmd, vaultName) return cli.VaultImport(cmd, vaultName)
}, },
@@ -104,18 +142,27 @@ func newVaultImportCmd() *cobra.Command {
} }
func newVaultRemoveCmd() *cobra.Command { func newVaultRemoveCmd() *cobra.Command {
cli := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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 := NewCLIInstance()
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.RemoveVault(cmd, args[0], force) return cli.RemoveVault(cmd, args[0], force)
}, },
@@ -136,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,
} }
@@ -149,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()
} }
@@ -175,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)
@@ -196,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)
@@ -229,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)
@@ -244,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 != "" {
secret.Debug("Using unlock passphrase from environment variable")
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 { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) return 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)
@@ -274,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
} }
@@ -283,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 // updateVaultImportMetadata stores the derivation info in vault metadata
ltPublicKey := ltIdentity.Recipient().String() func updateVaultImportMetadata(
secret.Debug("Storing long-term public key", "pubkey", ltPublicKey, "vault_dir", vaultDir) fs afero.Fs, vaultDir string, derivationIndex uint32,
publicKeyHash, familyHash string,
if err := afero.WriteFile(cli.fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms); err != nil { ) error {
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()))
// 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{
@@ -373,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")
@@ -397,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)
@@ -411,65 +535,27 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
return nil return nil
} }
// RemoveVault removes a vault with safety checks // vaultHasSecrets reports whether the vault directory contains any secrets
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error { func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
// Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to list vaults: %w", err)
}
// Check if vault exists
vaultExists := false
for _, v := range vaults {
if v == name {
vaultExists = true
break
}
}
if !vaultExists {
return fmt.Errorf("vault '%s' does not exist", name)
}
// Don't allow removing the last vault
if len(vaults) == 1 {
return fmt.Errorf("cannot remove the last vault")
}
// Check if this is the current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
isCurrentVault := currentVault.GetName() == name
// Load the vault to check for secrets
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Check if vault has secrets
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
hasSecrets := false
if exists, _ := afero.DirExists(cli.fs, secretsDir); exists { exists, _ := afero.DirExists(cli.fs, secretsDir)
if !exists {
return false
}
entries, err := afero.ReadDir(cli.fs, secretsDir) entries, err := afero.ReadDir(cli.fs, secretsDir)
if err == nil && len(entries) > 0 {
hasSecrets = true return err == nil && len(entries) > 0
}
} }
// Require --force if vault has secrets // switchAwayFromVault selects another vault as current before removal
if hasSecrets && !force { func (cli *Instance) switchAwayFromVault(
return fmt.Errorf("vault '%s' contains secrets; use --force to remove", name) cmd *cobra.Command, vaults []string, name string,
} ) error {
// If removing current vault, switch to another vault first
if isCurrentVault {
// Find another vault to switch to // Find another vault to switch to
var newVault string var newVault string
for _, v := range vaults { for _, v := range vaults {
if v != name { if v != name {
newVault = v newVault = v
@@ -479,20 +565,77 @@ func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) er
} }
// Switch to the new vault // Switch to the new vault
if err := vault.SelectVault(cli.fs, cli.stateDir, newVault); err != nil { err := vault.SelectVault(cli.fs, cli.stateDir, newVault)
if err != nil {
return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err) return fmt.Errorf("failed to switch to vault '%s': %w", newVault, err)
} }
cmd.Printf("Switched current vault to '%s'\n", newVault) cmd.Printf("Switched current vault to '%s'\n", newVault)
return nil
}
// RemoveVault removes a vault with safety checks
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
// Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to list vaults: %w", err)
}
// Check if vault exists
if !slices.Contains(vaults, name) {
return fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
// Don't allow removing the last vault
if len(vaults) == 1 {
return errCannotRemoveLastVault
}
// Check if this is the current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
isCurrentVault := currentVault.GetName() == name
// Load the vault to check for secrets
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Check if vault has secrets
hasSecrets := cli.vaultHasSecrets(vaultDir)
// Require --force if vault has secrets
if hasSecrets && !force {
return fmt.Errorf("vault '%s' %w", name, errVaultContainsSecrets)
}
// If removing current vault, switch to another vault first
if isCurrentVault {
err = cli.switchAwayFromVault(cmd, vaults, name)
if err != nil {
return err
}
} }
// 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

View File

@@ -1,11 +1,16 @@
package cli package cli
import ( import (
"errors"
"fmt" "fmt"
"io"
"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"
@@ -16,9 +21,18 @@ 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 := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
log.Fatalf("failed to initialize CLI: %v", err)
}
return VersionCommands(cli) return VersionCommands(cli)
} }
@@ -28,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
@@ -47,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 {
@@ -67,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 {
@@ -117,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
@@ -141,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 = ""
} }
@@ -156,19 +179,36 @@ 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 {
printVersionRow(w, vlt, secretName, version, currentVersion, ltIdentity)
}
_ = w.Flush()
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) sv := secret.NewVersion(vlt, secretName, version)
// Load metadata // Load metadata
if err := sv.LoadMetadata(ltIdentity); err != nil { err := sv.LoadMetadata(ltIdentity)
secret.Debug("Failed to load version metadata", "version", version, "error", err) if err != nil {
secret.Warn("Failed to load version metadata",
"version", version, "error", err)
// Display version with error // Display version with error
status := "error" status := "error"
if version == currentVersion { if version == currentVersion {
status = "current (error)" status = "current (error)"
} }
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-") _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", version, "-", status, "-", "-")
continue return
} }
// Determine status // Determine status
@@ -178,31 +218,27 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
} }
// Format timestamps // Format timestamps
createdAt := "-" createdAt := formatVersionTime(sv.Metadata.CreatedAt)
if sv.Metadata.CreatedAt != nil { notBefore := formatVersionTime(sv.Metadata.NotBefore)
createdAt = sv.Metadata.CreatedAt.Format("2006-01-02 15:04:05") notAfter := formatVersionTime(sv.Metadata.NotAfter)
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n",
version, createdAt, status, notBefore, notAfter)
} }
notBefore := "-" // formatVersionTime formats an optional version timestamp, "-" when unset
if sv.Metadata.NotBefore != nil { func formatVersionTime(t *time.Time) string {
notBefore = sv.Metadata.NotBefore.Format("2006-01-02 15:04:05") if t == nil {
return "-"
} }
notAfter := "-" return t.Format("2006-01-02 15:04:05")
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()
return nil
} }
// 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 {
@@ -220,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)
} }
@@ -239,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 {
@@ -260,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
@@ -282,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)
} }

View File

@@ -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,15 +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 := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil {
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)
@@ -282,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"
@@ -298,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)

View File

@@ -0,0 +1,129 @@
//go:build darwin
// Package macse provides Go bindings for macOS Secure Enclave operations
// using CryptoTokenKit identities created via sc_auth.
// Key creation and deletion shell out to sc_auth (which has SE entitlements).
// Encrypt/decrypt use Security.framework ECIES directly (works unsigned).
package macse
/*
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Security -framework Foundation -framework CoreFoundation
#include <stdlib.h>
#include "secure_enclave.h"
*/
import "C"
import (
"fmt"
"unsafe"
)
const (
// p256UncompressedKeySize is the size of an uncompressed P-256 public key.
p256UncompressedKeySize = 65
// errorBufferSize is the size of the C error message buffer.
errorBufferSize = 512
// hashBufferSize is the size of the hash output buffer.
hashBufferSize = 128
// maxCiphertextSize is the max buffer for ECIES ciphertext.
// ECIES overhead for P-256: 65 (ephemeral pub) + 16 (GCM tag) + 16 (IV) + plaintext.
maxCiphertextSize = 8192
// maxPlaintextSize is the max buffer for decrypted plaintext.
maxPlaintextSize = 8192
)
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion).
func CreateKey(label string) (publicKey []byte, hash string, err error) {
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize)
var hashBuf [hashBufferSize]C.char
var errBuf [errorBufferSize]C.char
cLabel := C.CString(label)
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
result := C.se_create_key(cLabel,
&pubKeyBuf[0], &pubKeyLen,
&hashBuf[0], C.int(hashBufferSize),
&errBuf[0], C.int(errorBufferSize))
if result != 0 {
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
}
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
h := C.GoString(&hashBuf[0])
return pk, h, nil
}
// Encrypt encrypts plaintext using the SE-backed public key via ECIES
// (eciesEncryptionStandardVariableIVX963SHA256AESGCM).
// Encryption uses only the public key; no SE interaction required.
func Encrypt(label string, plaintext []byte) ([]byte, error) {
ciphertextBuf := make([]C.uint8_t, maxCiphertextSize)
ciphertextLen := C.int(maxCiphertextSize)
var errBuf [errorBufferSize]C.char
cLabel := C.CString(label)
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
result := C.se_encrypt(cLabel,
(*C.uint8_t)(unsafe.Pointer(&plaintext[0])), C.int(len(plaintext)),
&ciphertextBuf[0], &ciphertextLen,
&errBuf[0], C.int(errorBufferSize))
if result != 0 {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
}
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
return out, nil
}
// Decrypt decrypts ECIES ciphertext using the SE-backed private key.
// The ECDH portion of decryption is performed inside the Secure Enclave.
func Decrypt(label string, ciphertext []byte) ([]byte, error) {
plaintextBuf := make([]C.uint8_t, maxPlaintextSize)
plaintextLen := C.int(maxPlaintextSize)
var errBuf [errorBufferSize]C.char
cLabel := C.CString(label)
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
result := C.se_decrypt(cLabel,
(*C.uint8_t)(unsafe.Pointer(&ciphertext[0])), C.int(len(ciphertext)),
&plaintextBuf[0], &plaintextLen,
&errBuf[0], C.int(errorBufferSize))
if result != 0 {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
}
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
return out, nil
}
// DeleteKey removes a CTK identity from the Secure Enclave via sc_auth.
func DeleteKey(hash string) error {
var errBuf [errorBufferSize]C.char
cHash := C.CString(hash)
defer C.free(unsafe.Pointer(cHash)) //nolint:nlreturn // CGo free pattern
result := C.se_delete_key(cHash, &errBuf[0], C.int(errorBufferSize))
if result != 0 {
return fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
}
return nil
}

View File

@@ -0,0 +1,28 @@
//go:build !darwin
// Package macse provides Go bindings for macOS Secure Enclave operations.
package macse
import "errors"
var errNotSupported = errors.New("secure enclave is only supported on macOS")
// CreateKey is not supported on non-darwin platforms.
func CreateKey(_ string) ([]byte, string, error) {
return nil, "", errNotSupported
}
// Encrypt is not supported on non-darwin platforms.
func Encrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported
}
// Decrypt is not supported on non-darwin platforms.
func Decrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported
}
// DeleteKey is not supported on non-darwin platforms.
func DeleteKey(_ string) error {
return errNotSupported
}

View File

@@ -0,0 +1,163 @@
//go:build darwin
// +build darwin
package macse
import (
"bytes"
"testing"
)
const testKeyLabel = "berlin.sneak.app.secret.test.se-key"
// testKeyHash stores the hash of the created test key for cleanup.
var testKeyHash string //nolint:gochecknoglobals
// skipIfNoSecureEnclave skips the test if SE access is unavailable.
func skipIfNoSecureEnclave(t *testing.T) {
t.Helper()
probeLabel := "berlin.sneak.app.secret.test.se-probe"
_, hash, err := CreateKey(probeLabel)
if err != nil {
t.Skipf("Secure Enclave unavailable (skipping): %v", err)
}
if hash != "" {
_ = DeleteKey(hash)
}
}
func TestCreateAndDeleteKey(t *testing.T) {
skipIfNoSecureEnclave(t)
if testKeyHash != "" {
_ = DeleteKey(testKeyHash)
}
pubKey, hash, err := CreateKey(testKeyLabel)
if err != nil {
t.Fatalf("CreateKey failed: %v", err)
}
testKeyHash = hash
t.Logf("Created key with hash: %s", hash)
// Verify valid uncompressed P-256 public key
if len(pubKey) != p256UncompressedKeySize {
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey))
}
if pubKey[0] != 0x04 {
t.Fatalf("expected uncompressed point prefix 0x04, got 0x%02x", pubKey[0])
}
if hash == "" {
t.Fatal("expected non-empty hash")
}
// Delete the key
if err := DeleteKey(hash); err != nil {
t.Fatalf("DeleteKey failed: %v", err)
}
testKeyHash = ""
t.Log("Key created, verified, and deleted successfully")
}
func TestEncryptDecryptRoundTrip(t *testing.T) {
skipIfNoSecureEnclave(t)
_, hash, err := CreateKey(testKeyLabel)
if err != nil {
t.Fatalf("CreateKey failed: %v", err)
}
testKeyHash = hash
defer func() {
if testKeyHash != "" {
_ = DeleteKey(testKeyHash)
testKeyHash = ""
}
}()
// Test data simulating an age private key
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
// Encrypt
ciphertext, err := Encrypt(testKeyLabel, plaintext)
if err != nil {
t.Fatalf("Encrypt failed: %v", err)
}
t.Logf("Plaintext: %d bytes, Ciphertext: %d bytes", len(plaintext), len(ciphertext))
if bytes.Equal(ciphertext, plaintext) {
t.Fatal("ciphertext should differ from plaintext")
}
// Decrypt
decrypted, err := Decrypt(testKeyLabel, ciphertext)
if err != nil {
t.Fatalf("Decrypt failed: %v", err)
}
if !bytes.Equal(decrypted, plaintext) {
t.Fatalf("decrypted data does not match original plaintext")
}
t.Log("ECIES encrypt/decrypt round-trip successful")
}
func TestEncryptProducesDifferentCiphertexts(t *testing.T) {
skipIfNoSecureEnclave(t)
_, hash, err := CreateKey(testKeyLabel)
if err != nil {
t.Fatalf("CreateKey failed: %v", err)
}
testKeyHash = hash
defer func() {
if testKeyHash != "" {
_ = DeleteKey(testKeyHash)
testKeyHash = ""
}
}()
plaintext := []byte("test-secret-data")
ct1, err := Encrypt(testKeyLabel, plaintext)
if err != nil {
t.Fatalf("first Encrypt failed: %v", err)
}
ct2, err := Encrypt(testKeyLabel, plaintext)
if err != nil {
t.Fatalf("second Encrypt failed: %v", err)
}
// ECIES uses a random ephemeral key each time, so ciphertexts should differ
if bytes.Equal(ct1, ct2) {
t.Fatal("two encryptions of same plaintext should produce different ciphertexts")
}
// Both should decrypt to the same plaintext
dec1, err := Decrypt(testKeyLabel, ct1)
if err != nil {
t.Fatalf("first Decrypt failed: %v", err)
}
dec2, err := Decrypt(testKeyLabel, ct2)
if err != nil {
t.Fatalf("second Decrypt failed: %v", err)
}
if !bytes.Equal(dec1, plaintext) || !bytes.Equal(dec2, plaintext) {
t.Fatal("both ciphertexts should decrypt to original plaintext")
}
t.Log("ECIES correctly produces different ciphertexts that decrypt to same plaintext")
}

View File

@@ -0,0 +1,59 @@
//go:build darwin
#ifndef SECURE_ENCLAVE_H
#define SECURE_ENCLAVE_H
#include <stdint.h>
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
// label: unique identifier for the CTK identity (UTF-8 C string)
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
// pub_key_len: on input, size of pub_key_out; on output, actual size written
// hash_out: output buffer for the identity hash (for deletion)
// hash_out_len: size of hash_out buffer
// error_out: output buffer for error message
// error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure.
int se_create_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_len,
char *error_out, int error_out_len);
// se_encrypt encrypts data using the SE-backed public key (ECIES).
// label: label of the CTK identity whose public key to use
// plaintext: data to encrypt
// plaintext_len: length of plaintext
// ciphertext_out: output buffer for the ECIES ciphertext
// ciphertext_len: on input, size of buffer; on output, actual size written
// error_out: output buffer for error message
// error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure.
int se_encrypt(const char *label,
const uint8_t *plaintext, int plaintext_len,
uint8_t *ciphertext_out, int *ciphertext_len,
char *error_out, int error_out_len);
// se_decrypt decrypts ECIES ciphertext using the SE-backed private key.
// The ECDH portion of decryption is performed inside the Secure Enclave.
// label: label of the CTK identity whose private key to use
// ciphertext: ECIES ciphertext produced by se_encrypt
// ciphertext_len: length of ciphertext
// plaintext_out: output buffer for decrypted data
// plaintext_len: on input, size of buffer; on output, actual size written
// error_out: output buffer for error message
// error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure.
int se_decrypt(const char *label,
const uint8_t *ciphertext, int ciphertext_len,
uint8_t *plaintext_out, int *plaintext_len,
char *error_out, int error_out_len);
// se_delete_key removes a CTK identity from the Secure Enclave via sc_auth.
// hash: the identity hash returned by se_create_key
// error_out: output buffer for error message
// error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure.
int se_delete_key(const char *hash,
char *error_out, int error_out_len);
#endif // SECURE_ENCLAVE_H

View File

@@ -0,0 +1,302 @@
//go:build darwin
#import <Foundation/Foundation.h>
#import <Security/Security.h>
#include "secure_enclave.h"
#include <string.h>
// snprintf_error writes an error message string to the output buffer.
static void snprintf_error(char *error_out, int error_out_len, NSString *msg) {
if (error_out && error_out_len > 0) {
snprintf(error_out, error_out_len, "%s", msg.UTF8String);
}
}
// lookup_ctk_identity finds a CTK identity by label and returns the private key.
static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int error_out_len) {
NSDictionary *query = @{
(id)kSecClass: (id)kSecClassIdentity,
(id)kSecAttrLabel: [NSString stringWithUTF8String:label],
(id)kSecMatchLimit: (id)kSecMatchLimitOne,
(id)kSecReturnRef: @YES,
};
SecIdentityRef identity = NULL;
OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&identity);
if (status != errSecSuccess || !identity) {
NSString *msg = [NSString stringWithFormat:@"CTK identity '%s' not found: OSStatus %d",
label, (int)status];
snprintf_error(error_out, error_out_len, msg);
return NULL;
}
SecKeyRef privateKey = NULL;
status = SecIdentityCopyPrivateKey(identity, &privateKey);
CFRelease(identity);
if (status != errSecSuccess || !privateKey) {
NSString *msg = [NSString stringWithFormat:
@"failed to get private key from CTK identity '%s': OSStatus %d",
label, (int)status];
snprintf_error(error_out, error_out_len, msg);
return NULL;
}
return privateKey;
}
int se_create_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_len,
char *error_out, int error_out_len) {
@autoreleasepool {
NSString *labelStr = [NSString stringWithUTF8String:label];
// Shell out to sc_auth (which has SE entitlements) to create the key
NSTask *task = [[NSTask alloc] init];
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
task.arguments = @[
@"create-ctk-identity",
@"-k", @"p-256-ne",
@"-t", @"none",
@"-l", labelStr,
];
NSPipe *stderrPipe = [NSPipe pipe];
task.standardOutput = [NSPipe pipe];
task.standardError = stderrPipe;
NSError *nsError = nil;
if (![task launchAndReturnError:&nsError]) {
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
nsError.localizedDescription];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
[task waitUntilExit];
if (task.terminationStatus != 0) {
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
encoding:NSUTF8StringEncoding];
NSString *msg = [NSString stringWithFormat:@"sc_auth failed: %@",
stderrStr ?: @"unknown error"];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
// Retrieve the public key from the created identity
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
if (!privateKey) {
return -1;
}
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
CFRelease(privateKey);
if (!publicKey) {
snprintf_error(error_out, error_out_len, @"failed to get public key");
return -1;
}
CFErrorRef cfError = NULL;
CFDataRef pubKeyData = SecKeyCopyExternalRepresentation(publicKey, &cfError);
CFRelease(publicKey);
if (!pubKeyData) {
NSError *err = (__bridge_transfer NSError *)cfError;
NSString *msg = [NSString stringWithFormat:@"failed to export public key: %@",
err.localizedDescription];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
const UInt8 *bytes = CFDataGetBytePtr(pubKeyData);
CFIndex length = CFDataGetLength(pubKeyData);
if (length > *pub_key_len) {
CFRelease(pubKeyData);
snprintf_error(error_out, error_out_len, @"public key buffer too small");
return -1;
}
memcpy(pub_key_out, bytes, length);
*pub_key_len = (int)length;
CFRelease(pubKeyData);
// Get the identity hash by parsing sc_auth list output
hash_out[0] = '\0';
NSTask *listTask = [[NSTask alloc] init];
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
listTask.arguments = @[@"list-ctk-identities"];
NSPipe *listPipe = [NSPipe pipe];
listTask.standardOutput = listPipe;
listTask.standardError = [NSPipe pipe];
if ([listTask launchAndReturnError:&nsError]) {
[listTask waitUntilExit];
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
NSString *listStr = [[NSString alloc] initWithData:listData
encoding:NSUTF8StringEncoding];
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
if ([line containsString:labelStr]) {
NSMutableArray *tokens = [NSMutableArray array];
for (NSString *part in [line componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]) {
if (part.length > 0) {
[tokens addObject:part];
}
}
if (tokens.count > 1) {
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
}
break;
}
}
}
return 0;
}
}
int se_encrypt(const char *label,
const uint8_t *plaintext, int plaintext_len,
uint8_t *ciphertext_out, int *ciphertext_len,
char *error_out, int error_out_len) {
@autoreleasepool {
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
if (!privateKey) {
return -1;
}
SecKeyRef publicKey = SecKeyCopyPublicKey(privateKey);
CFRelease(privateKey);
if (!publicKey) {
snprintf_error(error_out, error_out_len, @"failed to get public key for encryption");
return -1;
}
NSData *plaintextData = [NSData dataWithBytes:plaintext length:plaintext_len];
CFErrorRef cfError = NULL;
CFDataRef encrypted = SecKeyCreateEncryptedData(
publicKey,
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
(__bridge CFDataRef)plaintextData,
&cfError
);
CFRelease(publicKey);
if (!encrypted) {
NSError *nsError = (__bridge_transfer NSError *)cfError;
NSString *msg = [NSString stringWithFormat:@"ECIES encryption failed: %@",
nsError.localizedDescription];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
const UInt8 *encBytes = CFDataGetBytePtr(encrypted);
CFIndex encLength = CFDataGetLength(encrypted);
if (encLength > *ciphertext_len) {
CFRelease(encrypted);
snprintf_error(error_out, error_out_len, @"ciphertext buffer too small");
return -1;
}
memcpy(ciphertext_out, encBytes, encLength);
*ciphertext_len = (int)encLength;
CFRelease(encrypted);
return 0;
}
}
int se_decrypt(const char *label,
const uint8_t *ciphertext, int ciphertext_len,
uint8_t *plaintext_out, int *plaintext_len,
char *error_out, int error_out_len) {
@autoreleasepool {
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
if (!privateKey) {
return -1;
}
NSData *ciphertextData = [NSData dataWithBytes:ciphertext length:ciphertext_len];
CFErrorRef cfError = NULL;
CFDataRef decrypted = SecKeyCreateDecryptedData(
privateKey,
kSecKeyAlgorithmECIESEncryptionStandardVariableIVX963SHA256AESGCM,
(__bridge CFDataRef)ciphertextData,
&cfError
);
CFRelease(privateKey);
if (!decrypted) {
NSError *nsError = (__bridge_transfer NSError *)cfError;
NSString *msg = [NSString stringWithFormat:@"ECIES decryption failed: %@",
nsError.localizedDescription];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
const UInt8 *decBytes = CFDataGetBytePtr(decrypted);
CFIndex decLength = CFDataGetLength(decrypted);
if (decLength > *plaintext_len) {
CFRelease(decrypted);
snprintf_error(error_out, error_out_len, @"plaintext buffer too small");
return -1;
}
memcpy(plaintext_out, decBytes, decLength);
*plaintext_len = (int)decLength;
CFRelease(decrypted);
return 0;
}
}
int se_delete_key(const char *hash,
char *error_out, int error_out_len) {
@autoreleasepool {
NSTask *task = [[NSTask alloc] init];
task.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
task.arguments = @[
@"delete-ctk-identity",
@"-h", [NSString stringWithUTF8String:hash],
];
NSPipe *stderrPipe = [NSPipe pipe];
task.standardOutput = [NSPipe pipe];
task.standardError = stderrPipe;
NSError *nsError = nil;
if (![task launchAndReturnError:&nsError]) {
NSString *msg = [NSString stringWithFormat:@"failed to launch sc_auth: %@",
nsError.localizedDescription];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
[task waitUntilExit];
if (task.terminationStatus != 0) {
NSData *stderrData = [stderrPipe.fileHandleForReading readDataToEndOfFile];
NSString *stderrStr = [[NSString alloc] initWithData:stderrData
encoding:NSUTF8StringEncoding];
NSString *msg = [NSString stringWithFormat:@"sc_auth delete failed: %@",
stderrStr ?: @"unknown error"];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
return 0;
}
}

View File

@@ -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"
) )

View File

@@ -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

View File

@@ -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,19 +63,36 @@ func IsDebugEnabled() bool {
return debugEnabled return debugEnabled
} }
// Warn logs a warning message to stderr unconditionally (visible
// without --verbose or debug flags)
func Warn(msg string, args ...any) {
var output strings.Builder
output.WriteString("WARNING: " + msg)
for i := 0; i+1 < len(args); i += 2 {
fmt.Fprintf(&output, " %s=%v", args[i], args[i+1])
}
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
func Debug(msg string, args ...any) { 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...))
} }
@@ -79,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...)
} }
@@ -108,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"
} }

View File

@@ -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) {

View File

@@ -0,0 +1,84 @@
//go:build darwin
package secret
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// realVault is a minimal VaultInterface backed by a real afero filesystem,
// using the same directory layout as vault.Vault.
type realVault struct {
name string
stateDir string
fs afero.Fs
}
func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
}
func (v *realVault) GetName() string { return v.name }
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
panic("not used")
}
// createRealVault sets up a complete vault directory structure on an in-memory
// filesystem, identical to what vault.CreateVault produces.
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", name)
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "secrets.d"), DirPerms))
require.NoError(t, fs.MkdirAll(filepath.Join(vaultDir, "unlockers.d"), DirPerms))
metadata := VaultMetadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
}
metaBytes, err := json.Marshal(metadata)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
return &realVault{name: name, stateDir: stateDir, fs: fs}
}
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive expected keys at two different indices to prove they differ.
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
require.NoError(t, err)
key5, err := agehd.DeriveIdentity(testMnemonic, 5)
require.NoError(t, err)
require.NotEqual(t, key0.String(), key5.String(),
"sanity check: different derivation indices must produce different keys")
// Build a real vault with DerivationIndex=5 on an in-memory filesystem.
fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic)
result, err := getLongTermPrivateKey(fs, vault)
require.NoError(t, err)
defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()),
"getLongTermPrivateKey should derive at vault's DerivationIndex (5)")
assert.NotEqual(t, key0.String(), string(result.Bytes()),
"getLongTermPrivateKey must not use hardcoded index 0")
}

View File

@@ -1,43 +1,23 @@
package secret package secret
import ( import (
"crypto/rand"
"fmt" "fmt"
"math/big"
"os" "os"
"path/filepath" "path/filepath"
) )
// generateRandomString generates a random string of the specified length using the given character set // DetermineStateDir determines the state directory based on environment
func generateRandomString(length int, charset string) (string, error) { // variables and OS.
if length <= 0 { // It returns an error if no usable directory can be determined.
return "", fmt.Errorf("length must be positive") func DetermineStateDir(customConfigDir string) (string, error) {
}
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := range length {
randomIndex, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return "", fmt.Errorf("failed to generate random number: %w", err)
}
result[i] = charset[randomIndex.Int64()]
}
return string(result), nil
}
// DetermineStateDir determines the state directory based on environment variables and OS
func DetermineStateDir(customConfigDir string) string {
// Check for environment variable first // Check for environment variable first
if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" { if envStateDir := os.Getenv(EnvStateDir); envStateDir != "" {
return envStateDir return envStateDir, nil
} }
// Use custom config dir if provided // Use custom config dir if provided
if customConfigDir != "" { if customConfigDir != "" {
return filepath.Join(customConfigDir, AppID) return filepath.Join(customConfigDir, AppID), nil
} }
// Use os.UserConfigDir() which handles platform-specific directories: // Use os.UserConfigDir() which handles platform-specific directories:
@@ -47,10 +27,19 @@ func DetermineStateDir(customConfigDir string) string {
configDir, err := os.UserConfigDir() configDir, err := os.UserConfigDir()
if err != nil { if err != nil {
// 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, _ := os.UserHomeDir() homeDir, homeErr := os.UserHomeDir()
if homeErr != nil {
return filepath.Join(homeDir, ".config", AppID) return "", fmt.Errorf(
"unable to determine state directory: config dir: %w, home dir: %w",
err, homeErr)
} }
return filepath.Join(configDir, AppID) fallbackDir := filepath.Join(homeDir, ".config", AppID)
Warn("Could not determine user config directory, falling back to default",
"fallback", fallbackDir, "error", err)
return fallbackDir, nil
}
return filepath.Join(configDir, AppID), nil
} }

View File

@@ -0,0 +1,29 @@
//go:build darwin
package secret
import (
"crypto/rand"
"fmt"
"math/big"
)
// generateRandomString generates a random string of the specified length using the given character set
func generateRandomString(length int, charset string) (string, error) {
if length <= 0 {
return "", fmt.Errorf("length must be positive")
}
result := make([]byte, length)
charsetLen := big.NewInt(int64(len(charset)))
for i := range length {
randomIndex, err := rand.Int(rand.Reader, charsetLen)
if err != nil {
return "", fmt.Errorf("failed to generate random number: %w", err)
}
result[i] = charset[randomIndex.Int64()]
}
return string(result), nil
}

View File

@@ -0,0 +1,59 @@
package secret_test
import (
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
)
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
// Clear all env vars that could provide a home/config directory.
// On Darwin, os.UserHomeDir may still succeed via the password
// database, so we also test via an explicit empty-customConfigDir
// path to exercise the fallback branch.
t.Setenv(secret.EnvStateDir, "")
t.Setenv("HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
result, err := secret.DetermineStateDir("")
// On systems where both lookups fail, we must get an error.
// On systems where the OS provides a fallback (e.g. macOS pw db),
// result should still be valid (non-empty, not root-relative).
if err != nil {
// Good — the error case is handled.
return
}
if result == "/.config/"+secret.AppID || result == "" {
t.Errorf(
"DetermineStateDir returned dangerous/empty path %q without error",
result)
}
}
func TestDetermineStateDir_UsesEnvVar(t *testing.T) {
t.Setenv(secret.EnvStateDir, "/custom/state")
result, err := secret.DetermineStateDir("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != "/custom/state" {
t.Errorf("expected /custom/state, got %q", result)
}
}
func TestDetermineStateDir_UsesCustomConfigDir(t *testing.T) {
t.Setenv(secret.EnvStateDir, "")
result, err := secret.DetermineStateDir("/my/config")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expected := "/my/config/" + secret.AppID
if result != expected {
t.Errorf("expected %q, got %q", expected, result)
}
}

View File

@@ -251,8 +251,25 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
// Check if mnemonic is available in environment variable // Check if mnemonic is available in environment variable
envMnemonic := os.Getenv(EnvMnemonic) envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" { if envMnemonic != "" {
// Use mnemonic directly to derive long-term key // Read vault metadata to get the correct derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0) vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
} }

View File

@@ -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
} }

View File

@@ -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 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")
} }

View File

@@ -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

View File

@@ -1,3 +1,5 @@
//go:build darwin
package secret_test package secret_test
import ( import (
@@ -140,7 +142,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
} }
defer os.RemoveAll(tempDir) // Clean up after test defer func() { _ = os.RemoveAll(tempDir) }() // Clean up after test
// Create a temporary GNUPGHOME // Create a temporary GNUPGHOME
gnupgHomeDir := filepath.Join(tempDir, "gnupg") gnupgHomeDir := filepath.Join(tempDir, "gnupg")

View File

@@ -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,20 +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("gpg", "--list-keys", "--with-colons", "--fingerprint", keyID) cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
context.Background(),
"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] != "" {
@@ -337,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
@@ -353,13 +428,18 @@ 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("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID) cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
context.Background(),
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
)
cmd.Stdin = strings.NewReader(data.String()) cmd.Stdin = strings.NewReader(data.String())
output, err := cmd.Output() output, err := cmd.Output()
@@ -372,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()

View File

@@ -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
} }

View File

@@ -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 return afero.WriteFile(m.fs, privKeyPath, encryptedPrivKey, 0o600)
} }
// Create current file pointing to the version (just the version name) // setupMockVaultDirs creates the vault directory structure, long-term
currentLink := filepath.Join(secretDir, "current") // public key, and current vault pointer for tests.
if err := afero.WriteFile(m.fs, currentLink, []byte(versionName), 0o600); err != nil { func setupMockVaultDirs(t *testing.T, fs afero.Fs, baseDir, vaultDir string) {
return err t.Helper()
}
return nil
}
func (m *MockVault) GetName() string {
return m.name
}
func (m *MockVault) GetFilesystem() afero.Fs {
return m.fs
}
func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, nil
}
func (m *MockVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
return nil, nil
}
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
// Set test mnemonic for direct encryption/decryption
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(EnvMnemonic, testMnemonic)
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
vaultDir := filepath.Join(baseDir, "vaults.d", "test-vault")
// 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")
}) })
} }
@@ -257,9 +315,10 @@ func isValidSecretName(name string) bool {
if name == "" { if name == "" {
return false return false
} }
// Valid characters for secret names: lowercase letters, numbers, dash, dot, underscore, slash // Valid characters for secret names: letters, numbers, dash, dot, underscore, slash
for _, char := range name { for _, char := range name {
if (char < 'a' || char > 'z') && // lowercase letters if (char < 'a' || char > 'z') && // lowercase letters
(char < 'A' || char > 'Z') && // uppercase letters
(char < '0' || char > '9') && // numbers (char < '0' || char > '9') && // numbers
char != '-' && // dash char != '-' && // dash
char != '.' && // dot char != '.' && // dot
@@ -273,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
@@ -283,13 +344,17 @@ func TestSecretNameValidation(t *testing.T) {
{"valid/path/name", true}, {"valid/path/name", true},
{"123valid", true}, {"123valid", true},
{"", false}, {"", false},
{"Invalid-Name", false}, // uppercase not allowed {"Valid-Upper-Name", true}, // uppercase allowed
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true}, // real-world uppercase key ID
{"MixedCase/Path/Name", true}, // mixed case with path
{"invalid name", false}, // space not allowed {"invalid name", false}, // space not allowed
{"invalid@name", false}, // @ not allowed {"invalid@name", false}, // @ not allowed
} }
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(
@@ -308,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)
}() }()

View File

@@ -0,0 +1,385 @@
//go:build darwin
// +build darwin
package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
const (
// seKeyLabelPrefix is the prefix for Secure Enclave CTK identity labels.
seKeyLabelPrefix = "berlin.sneak.app.secret.se"
// seUnlockerType is the metadata type string for Secure Enclave unlockers.
seUnlockerType = "secure-enclave"
// seLongtermFilename is the filename for the SE-encrypted vault long-term private key.
seLongtermFilename = "longterm.age.se"
)
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"`
}
// SecureEnclaveUnlocker represents a Secure Enclave-protected unlocker.
type SecureEnclaveUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting SE unlocker identity",
slog.String("unlocker_id", s.GetID()),
)
// Get SE key label from metadata
seKeyLabel, _, err := s.getSEKeyInfo()
if err != nil {
return nil, fmt.Errorf("failed to get SE key info: %w", err)
}
// Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil {
return nil, fmt.Errorf(
"failed to read SE-encrypted long-term key: %w",
err,
)
}
DebugWith("Read SE-encrypted long-term key",
slog.Int("encrypted_length", len(encryptedData)),
)
// Decrypt using the Secure Enclave (ECDH happens inside SE hardware)
decryptedData, err := macse.Decrypt(seKeyLabel, encryptedData)
if err != nil {
return nil, fmt.Errorf(
"failed to decrypt long-term key with SE: %w",
err,
)
}
// Parse the decrypted long-term private key
ltIdentity, err := age.ParseX25519Identity(string(decryptedData))
// Clear sensitive data immediately
for i := range decryptedData {
decryptedData[i] = 0
}
if err != nil {
return nil, fmt.Errorf(
"failed to parse long-term private key: %w",
err,
)
}
DebugWith("Successfully decrypted long-term key via SE",
slog.String("unlocker_id", s.GetID()),
)
return ltIdentity, nil
}
// GetType implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetType() string {
return seUnlockerType
}
// GetMetadata implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
return s.Metadata
}
// GetDirectory implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetID() string {
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
createdAt := s.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
}
// Remove implements Unlocker interface.
func (s *SecureEnclaveUnlocker) Remove() error {
_, seKeyHash, err := s.getSEKeyInfo()
if err != nil {
Debug("Failed to get SE key info during removal", "error", err)
return fmt.Errorf("failed to get SE key info: %w", err)
}
if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash)
if err := macse.DeleteKey(seKeyHash); err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err)
}
}
Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := s.fs.RemoveAll(s.Directory); err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
}
Debug("Successfully removed SE unlocker", "unlocker_id", s.GetID())
return nil
}
// getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
}
var seMetadata SecureEnclaveUnlockerMetadata
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
}
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
}
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
enrollmentDate := time.Now().UTC().Format("2006-01-02")
return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentDate,
), nil
}
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used.
func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil {
return nil, err
}
vault, err := GetCurrentVault(fs, stateDir)
if err != nil {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
// Generate SE key label
seKeyLabel, err := generateSEKeyLabel(vault.GetName())
if err != nil {
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
}
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
err,
)
}
defer ltPrivKeyData.Destroy()
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term key with SE: %w",
err,
)
}
// Step 4: Create unlocker directory and write files
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf(
"failed to create unlocker directory: %w",
err,
)
}
// Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := afero.WriteFile(fs, ltKeyPath, encryptedLtKey, FilePerms); err != nil {
return nil, fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
// Write metadata
seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, "macos"},
},
SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash,
}
metadataBytes, err := json.MarshalIndent(seMetadata, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
if err := afero.WriteFile(fs, metadataPath, metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write metadata: %w", err)
}
return &SecureEnclaveUnlocker{
Directory: unlockerDir,
Metadata: seMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker.
func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
envMnemonic,
metadata.DerivationIndex,
)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
}
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
}
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf(
"failed to get current unlocker identity: %w",
err,
)
}
// All unlocker types store longterm.age in their directory
longtermPath := filepath.Join(
currentUnlocker.GetDirectory(),
"longterm.age",
)
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil {
return nil, fmt.Errorf(
"failed to read encrypted long-term key: %w",
err,
)
}
ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtKey,
currentIdentity,
)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key: %w", err)
}
return ltPrivKeyBuffer, nil
}

View File

@@ -0,0 +1,85 @@
//go:build !darwin
package secret
import (
"errors"
"filippo.io/age"
"github.com/spf13/afero"
)
// seUnlockerType is the type string for Secure Enclave unlockers.
const seUnlockerType = "secure-enclave"
var errSENotSupported = errors.New(
"secure enclave unlockers are only supported on macOS",
)
// SecureEnclaveUnlockerMetadata is a stub for non-Darwin platforms.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"`
}
// SecureEnclaveUnlocker is a stub for non-Darwin platforms.
type SecureEnclaveUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
}
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on
// non-Darwin platforms. The returned instance's methods that require
// macOS functionality will return errors.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity returns an error on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
return nil, errSENotSupported
}
// GetType returns the unlocker type.
func (s *SecureEnclaveUnlocker) GetType() string {
return seUnlockerType
}
// GetMetadata returns the unlocker metadata.
func (s *SecureEnclaveUnlocker) GetMetadata() UnlockerMetadata {
return s.Metadata
}
// GetDirectory returns the unlocker directory.
func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory
}
// GetID returns the unlocker ID.
func (s *SecureEnclaveUnlocker) GetID() string {
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
}
// Remove returns an error on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) Remove() error {
return errSENotSupported
}
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}

View File

@@ -0,0 +1,100 @@
//go:build !darwin
//nolint:testpackage // white-box test asserting unexported sentinel errors
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{seUnlockerType, "macos"},
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
// Test GetType returns correct type
assert.Equal(t, seUnlockerType, unlocker.GetType())
// Test GetMetadata returns the metadata we passed in
assert.Equal(t, metadata, unlocker.GetMetadata())
// Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory())
// Test GetID returns a formatted string with the creation timestamp
expectedID := "2026-01-15.10.30-secure-enclave"
assert.Equal(t, expectedID, unlocker.GetID())
}
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
err := unlocker.Remove()
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
}
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
assert.Nil(t, unlocker)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// Verify the stub implements the Unlocker interface
var _ Unlocker = unlocker
}

View File

@@ -0,0 +1,101 @@
//go:build darwin
// +build darwin
package secret
import (
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewSecureEnclaveUnlocker(t *testing.T) {
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "macos"},
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
require.NotNil(t, unlocker, "NewSecureEnclaveUnlocker should return a valid instance")
// Test GetType returns correct type
assert.Equal(t, seUnlockerType, unlocker.GetType())
// Test GetMetadata returns the metadata we passed in
assert.Equal(t, metadata, unlocker.GetMetadata())
// Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory())
}
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
// Verify the darwin implementation implements the Unlocker interface
var _ Unlocker = unlocker
}
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// ID should contain the timestamp and "secure-enclave" type
assert.Contains(t, id, "2026-03-10.14.30")
assert.Contains(t, id, seUnlockerType)
}
func TestGenerateSEKeyLabel(t *testing.T) {
label, err := generateSEKeyLabel("test-vault")
require.NoError(t, err)
// Label should contain the prefix and vault name
assert.Contains(t, label, seKeyLabelPrefix)
assert.Contains(t, label, "test-vault")
}
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker-missing"
// Create unlocker directory with metadata but no encrypted key file
require.NoError(t, fs.MkdirAll(dir, DirPerms))
metadataJSON := `{
"type": "secure-enclave",
"createdAt": "2026-01-15T10:30:00Z",
"seKeyLabel": "berlin.sneak.app.secret.se.test",
"seKeyHash": "abc123"
}`
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms))
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
}
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
// GetIdentity should fail because the encrypted longterm key file is missing
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
}

View File

@@ -0,0 +1,148 @@
//go:build darwin
package secret
import (
"testing"
)
func TestValidateKeychainItemName(t *testing.T) {
tests := []struct {
name string
itemName string
wantErr bool
}{
// Valid cases
{
name: "valid simple name",
itemName: "my-secret-key",
wantErr: false,
},
{
name: "valid name with dots",
itemName: "com.example.app.key",
wantErr: false,
},
{
name: "valid name with underscores",
itemName: "my_secret_key_123",
wantErr: false,
},
{
name: "valid alphanumeric",
itemName: "Secret123Key",
wantErr: false,
},
{
name: "valid with hyphen at start",
itemName: "-my-key",
wantErr: false,
},
{
name: "valid with dot at start",
itemName: ".hidden-key",
wantErr: false,
},
// Invalid cases
{
name: "empty item name",
itemName: "",
wantErr: true,
},
{
name: "item name with spaces",
itemName: "my secret key",
wantErr: true,
},
{
name: "item name with semicolon",
itemName: "key;rm -rf /",
wantErr: true,
},
{
name: "item name with pipe",
itemName: "key|cat /etc/passwd",
wantErr: true,
},
{
name: "item name with backticks",
itemName: "key`whoami`",
wantErr: true,
},
{
name: "item name with dollar sign",
itemName: "key$(whoami)",
wantErr: true,
},
{
name: "item name with quotes",
itemName: "key\"name",
wantErr: true,
},
{
name: "item name with single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -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)
@@ -154,144 +67,3 @@ func TestValidateGPGKeyID(t *testing.T) {
}) })
} }
} }
func TestValidateKeychainItemName(t *testing.T) {
tests := []struct {
name string
itemName string
wantErr bool
}{
// Valid cases
{
name: "valid simple name",
itemName: "my-secret-key",
wantErr: false,
},
{
name: "valid name with dots",
itemName: "com.example.app.key",
wantErr: false,
},
{
name: "valid name with underscores",
itemName: "my_secret_key_123",
wantErr: false,
},
{
name: "valid alphanumeric",
itemName: "Secret123Key",
wantErr: false,
},
{
name: "valid with hyphen at start",
itemName: "-my-key",
wantErr: false,
},
{
name: "valid with dot at start",
itemName: ".hidden-key",
wantErr: false,
},
// Invalid cases
{
name: "empty item name",
itemName: "",
wantErr: true,
},
{
name: "item name with spaces",
itemName: "my secret key",
wantErr: true,
},
{
name: "item name with semicolon",
itemName: "key;rm -rf /",
wantErr: true,
},
{
name: "item name with pipe",
itemName: "key|cat /etc/passwd",
wantErr: true,
},
{
name: "item name with backticks",
itemName: "key`whoami`",
wantErr: true,
},
{
name: "item name with dollar sign",
itemName: "key$(whoami)",
wantErr: true,
},
{
name: "item name with quotes",
itemName: "key\"name",
wantErr: true,
},
{
name: "item name with single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -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,7 +108,12 @@ 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 {
_, err := fmt.Sscanf(parts[1], "%03d", &serial)
if err != nil {
Warn("Skipping malformed version directory name",
"name", entry.Name(), "error", err)
continue continue
} }
@@ -113,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
@@ -122,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",
@@ -134,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)
@@ -149,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
} }
@@ -268,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)
} }
@@ -294,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)
} }
@@ -312,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),
@@ -343,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)
@@ -360,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())
@@ -373,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)
@@ -398,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")
@@ -407,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
} }
@@ -418,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())
@@ -454,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)
} }

View File

@@ -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
View 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")
)

View File

@@ -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,22 +48,54 @@ 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) {
testCurrentVaultFileHandling(t, fs, tempDir)
})
// Test secret operations with deeply nested paths
t.Run("DeepPathSecrets", func(t *testing.T) {
testDeepPathSecrets(t, fs, tempDir)
})
// Test key caching in GetOrDeriveLongTermKey
t.Run("KeyCaching", func(t *testing.T) {
testKeyCaching(t, fs, tempDir)
})
// Test vault name validation
t.Run("VaultNameValidation", func(t *testing.T) {
testVaultNameValidation(t, fs, tempDir)
})
// Test multiple vaults and switching between them
t.Run("MultipleVaults", func(t *testing.T) {
testMultipleVaults(t, fs, tempDir)
})
// Test adding a secret in one vault and verifying it's not visible in
// another
t.Run("VaultIsolation", func(t *testing.T) {
testVaultIsolation(t, fs, tempDir)
})
}
func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
stateDir := filepath.Join(tempDir, "currentvault-test") stateDir := filepath.Join(tempDir, "currentvault-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault // Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, "test-vault") vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -47,14 +108,15 @@ func TestVaultWithRealFilesystem(t *testing.T) {
// Verify the currentvault file exists and contains just the vault name // Verify the currentvault file exists and contains just the vault name
currentVaultPath := filepath.Join(stateDir, "currentvault") currentVaultPath := filepath.Join(stateDir, "currentvault")
currentVaultContents, err := os.ReadFile(currentVaultPath)
currentVaultContents, err := os.ReadFile(filepath.Clean(currentVaultPath))
if err != nil { if err != nil {
t.Fatalf("Failed to read currentvault file: %v", err) t.Fatalf("Failed to read currentvault file: %v", err)
} }
expectedVaultName := "test-vault" if string(currentVaultContents) != testVaultName {
if string(currentVaultContents) != expectedVaultName { t.Errorf("Expected currentvault to contain %q, got %q",
t.Errorf("Expected currentvault to contain %q, got %q", expectedVaultName, string(currentVaultContents)) testVaultName, string(currentVaultContents))
} }
// Test that ResolveVaultSymlink correctly resolves the path // Test that ResolveVaultSymlink correctly resolves the path
@@ -66,17 +128,21 @@ func TestVaultWithRealFilesystem(t *testing.T) {
if resolvedPath != vaultDir { if resolvedPath != vaultDir {
t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath) t.Errorf("Expected resolved path to be %s, got %s", vaultDir, resolvedPath)
} }
}) }
func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
// Test secret operations with deeply nested paths
t.Run("DeepPathSecrets", func(t *testing.T) {
stateDir := filepath.Join(tempDir, "deep-path-test") stateDir := filepath.Join(tempDir, "deep-path-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault - CreateVault now handles public key when mnemonic is in env // Create a test vault - CreateVault now handles public key when
vlt, err := vault.CreateVault(fs, stateDir, "test-vault") // mnemonic is in env
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -86,13 +152,15 @@ func TestVaultWithRealFilesystem(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)
} }
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir) vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil { if err != nil {
t.Fatalf("Failed to load vault metadata: %v", err) t.Fatalf("Failed to load vault metadata: %v", err)
} }
// Derive long-term key from mnemonic using the vault's derivation index // Derive long-term key from mnemonic using the vault's derivation index
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
vaultMetadata.DerivationIndex)
if err != nil { if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err) t.Fatalf("Failed to derive long-term key: %v", err)
} }
@@ -120,15 +188,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Fatalf("Failed to list secrets: %v", err) t.Fatalf("Failed to list secrets: %v", err)
} }
found := false if !slices.Contains(secrets, deepPath) {
for _, s := range secrets {
if s == deepPath {
found = true
break
}
}
if !found {
t.Errorf("Deep path secret not found in listed secrets") t.Errorf("Deep path secret not found in listed secrets")
} }
@@ -142,17 +202,21 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Errorf("Retrieved value doesn't match. Expected %q, got %q", t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
string(expectedValue), string(retrievedValue)) string(expectedValue), string(retrievedValue))
} }
}) }
func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
t.Helper()
// Test key caching in GetOrDeriveLongTermKey
t.Run("KeyCaching", func(t *testing.T) {
stateDir := filepath.Join(tempDir, "key-cache-test") stateDir := filepath.Join(tempDir, "key-cache-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault - CreateVault now handles public key when mnemonic is in env // Create a test vault - CreateVault now handles public key when
vlt, err := vault.CreateVault(fs, stateDir, "test-vault") // mnemonic is in env
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -162,13 +226,16 @@ func TestVaultWithRealFilesystem(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)
} }
vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir) vaultMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil { if err != nil {
t.Fatalf("Failed to load vault metadata: %v", err) t.Fatalf("Failed to load vault metadata: %v", err)
} }
// Derive long-term key from mnemonic for verification using the vault's derivation index // Derive long-term key from mnemonic for verification using the
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, vaultMetadata.DerivationIndex) // vault's derivation index
ltIdentity, err := agehd.DeriveIdentity(testMnemonic,
vaultMetadata.DerivationIndex)
if err != nil { if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err) t.Fatalf("Failed to derive long-term key: %v", err)
} }
@@ -202,13 +269,16 @@ func TestVaultWithRealFilesystem(t *testing.T) {
// Verify the public key matches what we expect // Verify the public key matches what we expect
expectedPubKey := ltIdentity.Recipient().String() expectedPubKey := ltIdentity.Recipient().String()
actualPubKey := firstKey.Recipient().String() actualPubKey := firstKey.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)
} }
// Now clear the key and verify it's locked again // Now clear the key and verify it's locked again
vlt.ClearLongTermKey() vlt.ClearLongTermKey()
if !vlt.Locked() { if !vlt.Locked() {
t.Errorf("Vault should be locked after clearing key") t.Errorf("Vault should be locked after clearing key")
} }
@@ -222,14 +292,18 @@ func TestVaultWithRealFilesystem(t *testing.T) {
// Verify the public key still matches // Verify the public key still matches
actualPubKey = thirdKey.Recipient().String() actualPubKey = thirdKey.Recipient().String()
if actualPubKey != expectedPubKey { if actualPubKey != expectedPubKey {
t.Errorf("Re-derived public key mismatch. Expected %s, got %s", expectedPubKey, actualPubKey) t.Errorf("Re-derived public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
} }
})
// Test vault name validation func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
t.Run("VaultNameValidation", func(t *testing.T) { t.Helper()
stateDir := filepath.Join(tempDir, "name-validation-test") stateDir := filepath.Join(tempDir, "name-validation-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
@@ -261,15 +335,19 @@ func TestVaultWithRealFilesystem(t *testing.T) {
for _, name := range invalidNames { for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name)
if err == nil { if err == nil {
t.Errorf("Expected error creating vault with invalid name %q, but got none", name) t.Errorf("Expected error creating vault with invalid name %q, "+
"but got none", name)
}
} }
} }
})
// Test multiple vaults and switching between them func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
t.Run("MultipleVaults", func(t *testing.T) { t.Helper()
stateDir := filepath.Join(tempDir, "multi-vault-test") stateDir := filepath.Join(tempDir, "multi-vault-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
@@ -295,30 +373,37 @@ func TestVaultWithRealFilesystem(t *testing.T) {
// Test switching between vaults // Test switching between vaults
for _, name := range vaultNames { for _, name := range vaultNames {
// Select the vault // Select the vault
if err := vault.SelectVault(fs, stateDir, name); err != nil { err := vault.SelectVault(fs, stateDir, name)
if err != nil {
t.Fatalf("Failed to select vault %s: %v", name, err) t.Fatalf("Failed to select vault %s: %v", name, err)
} }
// Get current vault and verify it's the one we selected // Get current vault and verify it's the one we selected
currentVault, err := vault.GetCurrentVault(fs, stateDir) currentVault, err := vault.GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to get current vault after selecting %s: %v", name, err) t.Fatalf("Failed to get current vault after selecting %s: %v",
name, err)
} }
if currentVault.GetName() != name { if currentVault.GetName() != name {
t.Errorf("Expected current vault to be %s, got %s", name, currentVault.GetName()) 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 func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
t.Run("VaultIsolation", func(t *testing.T) { t.Helper()
stateDir := filepath.Join(tempDir, "isolation-test") stateDir := filepath.Join(tempDir, "isolation-test")
if err := os.MkdirAll(stateDir, 0o700); err != nil {
err := os.MkdirAll(stateDir, 0o700)
if err != nil {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create two vaults - CreateVault now handles public key when mnemonic is in env // Create two vaults - CreateVault now handles public key when mnemonic
// is in env
vault1, err := vault.CreateVault(fs, stateDir, "vault1") vault1, err := vault.CreateVault(fs, stateDir, "vault1")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault1: %v", err) t.Fatalf("Failed to create vault1: %v", err)
@@ -329,51 +414,24 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Fatalf("Failed to create vault2: %v", err) t.Fatalf("Failed to create vault2: %v", err)
} }
// Derive long-term key from mnemonic // Derive long-term keys from mnemonic
// Note: Both vaults will have different derivation indexes due to GetNextDerivationIndex // Note: Both vaults will have different derivation indexes due to
// GetNextDerivationIndex
// Load vault1 metadata to get its derivation index ltIdentity1 := deriveVaultIdentity(t, fs, vault1)
vault1Dir, err := vault1.GetDirectory() ltIdentity2 := deriveVaultIdentity(t, fs, vault2)
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 // Unlock the vaults with their respective keys
vault1.Unlock(ltIdentity1) vault1.Unlock(ltIdentity1)
vault2.Unlock(ltIdentity2) vault2.Unlock(ltIdentity2)
// Add a secret to vault1 // Add a secret to vault1
secretName := "test-secret"
secretValue := []byte("secret in vault1") secretValue := []byte("secret in vault1")
secretBuffer := memguard.NewBufferFromBytes(secretValue) secretBuffer := memguard.NewBufferFromBytes(secretValue)
defer secretBuffer.Destroy() defer secretBuffer.Destroy()
if err := vault1.AddSecret(secretName, secretBuffer, false); err != nil { err = vault1.AddSecret(testSecretName, secretBuffer, false)
if err != nil {
t.Fatalf("Failed to add secret to vault1: %v", err) t.Fatalf("Failed to add secret to vault1: %v", err)
} }
@@ -383,15 +441,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Fatalf("Failed to list secrets in vault1: %v", err) t.Fatalf("Failed to list secrets in vault1: %v", err)
} }
found := false if !slices.Contains(vault1Secrets, testSecretName) {
for _, s := range vault1Secrets {
if s == secretName {
found = true
break
}
}
if !found {
t.Errorf("Secret not found in vault1") t.Errorf("Secret not found in vault1")
} }
@@ -401,16 +451,7 @@ func TestVaultWithRealFilesystem(t *testing.T) {
t.Fatalf("Failed to list secrets in vault2: %v", err) t.Fatalf("Failed to list secrets in vault2: %v", err)
} }
found = false if slices.Contains(vault2Secrets, testSecretName) {
for _, s := range vault2Secrets {
if s == secretName {
found = true
break
}
}
if found {
t.Errorf("Secret from vault1 should not be visible in vault2") t.Errorf("Secret from vault1 should not be visible in vault2")
} }
})
} }

View File

@@ -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,7 +74,47 @@ 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)
})
// Step 2: Create second version
t.Run("create_second_version", func(t *testing.T) {
testCreateSecondVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
})
// Step 3: Create third version
t.Run("create_third_version", func(t *testing.T) {
testCreateThirdVersion(t, fs, vault, vaultDir, secretName)
})
// Step 4: Retrieve specific versions
t.Run("retrieve_specific_versions", func(t *testing.T) {
testRetrieveSpecificVersions(t, fs, vault, vaultDir, secretName)
})
// Step 5: Promote old version to current
t.Run("promote_old_version", func(t *testing.T) {
testPromoteOldVersion(t, fs, vault, ltIdentity, vaultDir, secretName)
})
// Step 6: Test version limits
t.Run("version_serial_limits", func(t *testing.T) {
testVersionSerialLimits(t, fs, vault, vaultDir)
})
// Step 7: Test error cases
t.Run("error_cases", func(t *testing.T) {
testVersionErrorCases(t, vault, secretName)
})
}
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 // Verify secret can be retrieved
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
@@ -101,23 +139,29 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
assert.NotNil(t, version.Metadata.CreatedAt) assert.NotNil(t, version.Metadata.CreatedAt)
assert.NotNil(t, version.Metadata.NotBefore) assert.NotNil(t, version.Metadata.NotBefore)
assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1 assert.Equal(t, int64(1), version.Metadata.NotBefore.Unix()) // epoch + 1
assert.Nil(t, version.Metadata.NotAfter) // should be nil for current version // 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()
// Step 2: Create second version
var firstVersionName string
t.Run("create_second_version", func(t *testing.T) {
// Small delay to ensure different timestamps // Small delay to ensure different timestamps
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
// Get first version name before creating second // Get first version name before creating second
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test") secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir) versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err) require.NoError(t, err)
firstVersionName = versions[0]
firstVersionName := versions[0]
// Create second version // Create second version
addTestSecret(t, vault, secretName, []byte("version-2-data"), true) addTestSecretToVault(t, vault, secretName, []byte("version-2-data"), true)
// Verify new value is current // Verify new value is current
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
@@ -143,14 +187,18 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) assert.Nil(t, secondVersion.Metadata.NotAfter)
// NotBefore of second should equal NotAfter of first // NotBefore of second should equal NotAfter of first
assert.Equal(t, firstVersion.Metadata.NotAfter.Unix(), secondVersion.Metadata.NotBefore.Unix()) 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()
// Step 3: Create third version
t.Run("create_third_version", func(t *testing.T) {
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
addTestSecret(t, vault, secretName, []byte("version-3-data"), true) addTestSecretToVault(t, vault, secretName, []byte("version-3-data"), true)
// Verify we now have three versions // Verify we now have three versions
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test") secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
@@ -162,10 +210,13 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
value, err := vault.GetSecret(secretName) value, err := vault.GetSecret(secretName)
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value) assert.Equal(t, []byte("version-3-data"), value)
}) }
func testRetrieveSpecificVersions(
t *testing.T, fs afero.Fs, vault *Vault, vaultDir, secretName string,
) {
t.Helper()
// Step 4: Retrieve specific versions
t.Run("retrieve_specific_versions", func(t *testing.T) {
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test") secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir) versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err) require.NoError(t, err)
@@ -188,10 +239,14 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
valueCurrent, err := vault.GetSecretVersion(secretName, "") valueCurrent, err := vault.GetSecretVersion(secretName, "")
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), valueCurrent) 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()
// Step 5: Promote old version to current
t.Run("promote_old_version", func(t *testing.T) {
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test") secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
versions, err := secret.ListVersions(fs, secretDir) versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err) require.NoError(t, err)
@@ -211,17 +266,21 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
version := secret.NewVersion(vault, secretName, oldestVersion) version := secret.NewVersion(vault, secretName, oldestVersion)
err = version.LoadMetadata(ltIdentity) err = version.LoadMetadata(ltIdentity)
require.NoError(t, err) require.NoError(t, err)
assert.NotNil(t, version.Metadata.NotAfter) // should still have its old notAfter // 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()
// Step 6: Test version limits
t.Run("version_serial_limits", func(t *testing.T) {
// Create a new secret for this test // Create a new secret for this test
limitSecretName := "limit/test" limitSecretName := "limit/test"
secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions") secretDir := filepath.Join(vaultDir, "secrets.d", "limit%test", "versions")
// Create 998 versions (we already have one from the first AddSecret) // Create 998 versions (we already have one from the first AddSecret)
addTestSecret(t, vault, limitSecretName, []byte("initial"), false) addTestSecretToVault(t, vault, limitSecretName, []byte("initial"), false)
// Get today's date for consistent version names // Get today's date for consistent version names
today := time.Now().Format("20060102") today := time.Now().Format("20060102")
@@ -237,7 +296,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
// Should be able to create one more (999) // Should be able to create one more (999)
versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir)) versionName, err := secret.GenerateVersionName(fs, filepath.Dir(secretDir))
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%s.999", today), versionName) assert.Equal(t, today+".999", versionName)
// Create the 999th version directory // Create the 999th version directory
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755) err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
@@ -245,56 +304,59 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
// Should fail to create 1000th version // Should fail to create 1000th version
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir)) _, err = secret.GenerateVersionName(fs, filepath.Dir(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 testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
t.Helper()
// Step 7: Test error cases
t.Run("error_cases", func(t *testing.T) {
// Try to get non-existent version // Try to get non-existent version
_, err := vault.GetSecretVersion(secretName, "99991231.999") _, err := vault.GetSecretVersion(secretName, "99991231.999")
assert.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
// Try to get version of non-existent secret // Try to get version of non-existent secret
_, err = vault.GetSecretVersion("nonexistent/secret", "") _, err = vault.GetSecretVersion("nonexistent/secret", "")
assert.Error(t, err) require.Error(t, err)
// Try to add secret without force when it exists // Try to add secret without force when it exists
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail")) failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
defer failBuffer.Destroy() defer failBuffer.Destroy()
err = vault.AddSecret(secretName, failBuffer, false) err = vault.AddSecret(secretName, failBuffer, false)
assert.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "already exists") 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)

View File

@@ -15,8 +15,11 @@ 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(
func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
return GetCurrentVault(fs, stateDir) return GetCurrentVault(fs, stateDir)
}) })
} }
@@ -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)
} }

View File

@@ -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)
} }

View File

@@ -1,25 +1,42 @@
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) {
testComputeDoubleSHA256(t)
})
t.Run("GetNextDerivationIndex", func(t *testing.T) {
testGetNextDerivationIndex(t, fs)
})
t.Run("MetadataPersistence", func(t *testing.T) {
testMetadataPersistence(t, fs)
})
t.Run("DifferentKeysForDifferentIndices", func(t *testing.T) {
testDifferentKeysForDifferentIndices(t)
})
}
func testComputeDoubleSHA256(t *testing.T) {
t.Helper()
// Test data // Test data
data := []byte("test data") data := []byte("test data")
hash := ComputeDoubleSHA256(data) hash := vault.ComputeDoubleSHA256(data)
// Verify it's a valid hex string of 64 characters (32 bytes * 2) // Verify it's a valid hex string of 64 characters (32 bytes * 2)
if len(hash) != 64 { if len(hash) != 64 {
@@ -27,146 +44,166 @@ func TestVaultMetadata(t *testing.T) {
} }
// Verify consistency // Verify consistency
hash2 := ComputeDoubleSHA256(data) hash2 := vault.ComputeDoubleSHA256(data)
if hash != hash2 { if hash != hash2 {
t.Errorf("Hash should be consistent for same input") t.Errorf("Hash should be consistent for same input")
} }
// Verify different input produces different hash // Verify different input produces different hash
hash3 := ComputeDoubleSHA256([]byte("different data")) hash3 := vault.ComputeDoubleSHA256([]byte("different data"))
if hash == hash3 { if hash == hash3 {
t.Errorf("Different input should produce different hash") 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()
t.Run("GetNextDerivationIndex", func(t *testing.T) {
// Test with no existing vaults // Test with no existing vaults
index, err := GetNextDerivationIndex(fs, stateDir, testMnemonic) index, err := vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
if err != nil { if err != nil {
t.Fatalf("Failed to get derivation index: %v", err) t.Fatalf("Failed to get derivation index: %v", err)
} }
if index != 0 { if index != 0 {
t.Errorf("Expected index 0 for first vault, got %d", index) t.Errorf("Expected index 0 for first vault, got %d", index)
} }
// Create a vault with metadata and matching public key // Create a vault with metadata and matching public key (index 0; the
vaultDir := filepath.Join(stateDir, "vaults.d", "vault1") // family hash is the index 0 key hash)
if err := fs.MkdirAll(vaultDir, 0o700); err != nil { pubKeyHash0 := createVaultDirWithMetadata(t, fs, "vault1", 0, "")
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 // Next index for same mnemonic should be 1
index, err = GetNextDerivationIndex(fs, stateDir, testMnemonic) index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
if err != nil { if err != nil {
t.Fatalf("Failed to get derivation index: %v", err) t.Fatalf("Failed to get derivation index: %v", err)
} }
if index != 1 { if index != 1 {
t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index) t.Errorf("Expected index 1 for second vault with same mnemonic, got %d", index)
} }
// Different mnemonic should start at 0 // 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" differentMnemonic := "zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo zoo wrong"
index, err = GetNextDerivationIndex(fs, stateDir, differentMnemonic)
index, err = vault.GetNextDerivationIndex(fs, testStateDir, differentMnemonic)
if err != nil { if err != nil {
t.Fatalf("Failed to get derivation index: %v", err) t.Fatalf("Failed to get derivation index: %v", err)
} }
if index != 0 { if index != 0 {
t.Errorf("Expected index 0 for first vault with different mnemonic, got %d", index) t.Errorf("Expected index 0 for first vault with different mnemonic, got %d",
index)
} }
// Add another vault with same mnemonic but higher index // Add another vault with same mnemonic but higher index (5), sharing
vaultDir2 := filepath.Join(stateDir, "vaults.d", "vault2") // the same family hash since it's from the same mnemonic
if err := fs.MkdirAll(vaultDir2, 0o700); err != nil { createVaultDirWithMetadata(t, fs, "vault2", 5, pubKeyHash0)
t.Fatalf("Failed to create vault directory: %v", err)
}
// Derive identity for index 5 // Next index should be 1 (not 6): we look for the first available slot
identity5, err := agehd.DeriveIdentity(testMnemonic, 5) index, err = vault.GetNextDerivationIndex(fs, testStateDir, testMnemonic)
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 { if err != nil {
t.Fatalf("Failed to get derivation index: %v", err) t.Fatalf("Failed to get derivation index: %v", err)
} }
if index != 1 { if index != 1 {
t.Errorf("Expected index 1 (first available), got %d", index) t.Errorf("Expected index 1 (first available), got %d", index)
} }
}) }
t.Run("MetadataPersistence", func(t *testing.T) { func testMetadataPersistence(t *testing.T, fs afero.Fs) {
vaultDir := filepath.Join(stateDir, "vaults.d", "test-vault") t.Helper()
if err := fs.MkdirAll(vaultDir, 0o700); err != nil {
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
err := fs.MkdirAll(vaultDir, 0o700)
if err != nil {
t.Fatalf("Failed to create vault directory: %v", err) t.Fatalf("Failed to create vault directory: %v", err)
} }
// Create and save metadata // Create and save metadata
metadata := &Metadata{ metadata := &vault.Metadata{
DerivationIndex: 3, DerivationIndex: 3,
PublicKeyHash: "test-public-key-hash", PublicKeyHash: "test-public-key-hash",
} }
if err := SaveVaultMetadata(fs, vaultDir, metadata); err != nil { err = vault.SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
t.Fatalf("Failed to save metadata: %v", err) t.Fatalf("Failed to save metadata: %v", err)
} }
// Load and verify // Load and verify
loaded, err := LoadVaultMetadata(fs, vaultDir) loaded, err := vault.LoadVaultMetadata(fs, vaultDir)
if err != nil { if err != nil {
t.Fatalf("Failed to load metadata: %v", err) t.Fatalf("Failed to load metadata: %v", err)
} }
if loaded.DerivationIndex != metadata.DerivationIndex { if loaded.DerivationIndex != metadata.DerivationIndex {
t.Errorf("DerivationIndex mismatch: expected %d, got %d", metadata.DerivationIndex, loaded.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) { 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 // Derive keys with different indices
identity0, err := agehd.DeriveIdentity(testMnemonic, 0) identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil { if err != nil {
@@ -181,28 +218,26 @@ func TestVaultMetadata(t *testing.T) {
// Compute public key hashes // Compute public key hashes
pubKey0 := identity0.Recipient().String() pubKey0 := identity0.Recipient().String()
pubKey1 := identity1.Recipient().String() pubKey1 := identity1.Recipient().String()
hash0 := ComputeDoubleSHA256([]byte(pubKey0)) hash0 := vault.ComputeDoubleSHA256([]byte(pubKey0))
// Verify different indices produce different public keys // Verify different indices produce different public keys
if pubKey0 == pubKey1 { if pubKey0 == pubKey1 {
t.Errorf("Different derivation indices should produce different public keys") 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 // But the hash of index 0's public key should be the same for the same
// This is what we use as the identifier // mnemonic. This is what we use as the identifier
identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0) identity0Again, _ := agehd.DeriveIdentity(testMnemonic, 0)
pubKey0Again := identity0Again.Recipient().String() pubKey0Again := identity0Again.Recipient().String()
hash0Again := ComputeDoubleSHA256([]byte(pubKey0Again)) hash0Again := vault.ComputeDoubleSHA256([]byte(pubKey0Again))
if hash0 != hash0Again { if hash0 != hash0Again {
t.Errorf("Same mnemonic should produce same public key hash for index 0") 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!")
} }
} }
} }

View File

@@ -0,0 +1,95 @@
package vault_test
import (
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts.
// 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) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
require.NoError(t, err)
// Add a legitimate secret so the vault is set up
value := memguard.NewBufferFromBytes([]byte("legitimate-secret"))
err = vlt.AddSecret("legit", value, false)
require.NoError(t, err)
// These names contain path traversal and should be rejected
maliciousNames := []string{
"../../../etc/passwd",
"..%2f..%2fetc/passwd",
".secret",
"../sibling-vault/secrets.d/target",
"foo/../bar",
"a/../../etc/passwd",
}
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
_, err := vlt.GetSecretVersion(name, "")
require.Error(t, err,
"GetSecretVersion should reject malicious name: %s", name)
require.Contains(t, err.Error(), "invalid secret name",
"error should indicate invalid name for: %s", name)
})
}
}
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
// GetSecretVersion) also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid secret name")
}
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts.
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
require.NoError(t, err)
maliciousNames := []string{
"../../../etc/passwd",
"foo/../bar",
"a/../../etc/passwd",
}
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
_, err := vlt.GetSecretObject(name)
require.Error(t, err, "GetSecretObject should reject: %s", name)
require.Contains(t, err.Error(), "invalid secret name")
})
}
}

View File

@@ -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
@@ -67,7 +74,7 @@ func (v *Vault) ListSecrets() ([]string, error) {
return secrets, nil return secrets, nil
} }
// isValidSecretName validates secret names according to the format [a-z0-9\.\-\_\/]+ // isValidSecretName validates secret names according to the format [a-zA-Z0-9\.\-\_\/]+
// but with additional restrictions: // but with additional restrictions:
// - No leading or trailing slashes // - No leading or trailing slashes
// - No double slashes // - No double slashes
@@ -92,8 +99,13 @@ func isValidSecretName(name string) bool {
return false return false
} }
// Check for path traversal via ".." components
if slices.Contains(strings.Split(name, "/"), "..") {
return false
}
// Check the basic pattern // Check the basic pattern
matched, _ := regexp.MatchString(`^[a-z0-9\.\-\_\/]+$`, name) matched, _ := regexp.MatchString(`^[a-zA-Z0-9\.\-\_\/]+$`, name)
return matched return matched
} }
@@ -101,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",
@@ -115,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
@@ -137,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
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
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 { if err != nil {
secret.Debug("Failed to get long-term key for metadata update", "error", err) return 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)
@@ -256,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)
@@ -287,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)
} }
@@ -311,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),
@@ -319,62 +263,17 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
slog.String("version", version), slog.String("version", version),
) )
// Get vault directory // Validate the name and resolve the version to fetch
vaultDir, err := v.GetDirectory() version, err := v.resolveSecretVersion(name, version)
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()
@@ -392,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)
} }
@@ -428,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
} }
@@ -436,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)
} }
@@ -454,6 +358,10 @@ 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) {
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
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
@@ -469,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
} }
@@ -508,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)
} }
@@ -519,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)
} }
@@ -553,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
@@ -597,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
@@ -607,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",
@@ -638,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
}

View File

@@ -0,0 +1,47 @@
//nolint:testpackage // white-box test of unexported isValidSecretName
package vault
import "testing"
func TestIsValidSecretNameUppercase(t *testing.T) {
t.Parallel()
tests := []struct {
name string
valid bool
}{
// Lowercase (existing behavior)
{"valid-name", true},
{"valid.name", true},
{"valid_name", true},
{"valid/path/name", true},
{"123valid", true},
// Uppercase (new behavior - issue #2)
{"Valid-Upper-Name", true},
{"2025-11-21-ber1app1-vaultik-test-bucket-AKI", true},
{"MixedCase/Path/Name", true},
{"ALLUPPERCASE", true},
{"ABC123", true},
// Still invalid
{"", false},
{"invalid name", false},
{"invalid@name", false},
{".dotstart", false},
{"/leading-slash", false},
{"trailing-slash/", false},
{"double//slash", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
result := isValidSecretName(tt.name)
if result != tt.valid {
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
}
})
}
}

View File

@@ -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)

View File

@@ -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,46 +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 unlockerTypeSecureEnclave:
secret.Debug("Creating secure enclave unlocker instance",
"unlocker_type", metadata.Type)
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",
@@ -98,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)
} }
@@ -122,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)
@@ -136,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
@@ -147,25 +159,32 @@ 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 unlockerTypeSecureEnclave:
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
default: default:
continue continue
} }
@@ -193,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
} }
@@ -204,26 +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 {
return nil, fmt.Errorf("unlocker directory %s is missing metadata file", file.Name()) secret.Warn("Skipping unlocker directory with missing metadata file",
"directory", file.Name())
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)
@@ -250,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
@@ -274,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)
} }
} }
@@ -294,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)
} }
@@ -303,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)
} }
@@ -321,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{},
} }
@@ -358,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)
} }
@@ -372,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)
} }
@@ -386,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
}

View File

@@ -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()
@@ -129,55 +89,12 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
slog.String("unlocker_id", unlocker.GetID()), slog.String("unlocker_id", unlocker.GetID()),
) )
// Get unlocker identity // Get the long-term key via the unlocker.
unlockerIdentity, err := unlocker.GetIdentity() // SE unlockers return the long-term key directly from GetIdentity().
// Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil { if err != nil {
secret.Debug("Failed to get unlocker identity", "error", err, "unlocker_type", unlocker.GetType()) return nil, err
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
// Read encrypted long-term private key from unlocker directory
unlockerDir := unlocker.GetDirectory()
encryptedLtPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
secret.Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
secret.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)
}
secret.DebugWith("Read encrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("encrypted_length", len(encryptedLtPrivKey)),
)
// Decrypt long-term private key using unlocker
secret.Debug("Decrypting long-term private key with unlocker", "unlocker_type", unlocker.GetType())
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(encryptedLtPrivKey, unlockerIdentity)
if err != nil {
secret.Debug("Failed to decrypt long-term private key", "error", err, "unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
secret.DebugWith("Successfully decrypted long-term private key",
slog.String("vault_name", v.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.Int("decrypted_length", ltPrivKeyBuffer.Size()),
)
// Parse long-term private key
secret.Debug("Parsing long-term private key", "vault_name", v.Name)
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
secret.Debug("Failed to parse long-term private key", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
} }
secret.DebugWith("Successfully obtained long-term identity via unlocker", secret.DebugWith("Successfully obtained long-term identity via unlocker",
@@ -204,7 +121,10 @@ func (v *Vault) GetName() string {
return v.Name return v.Name
} }
// GetFilesystem returns the vault's filesystem (for VaultInterface compatibility) // 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 { func (v *Vault) GetFilesystem() afero.Fs {
return v.fs return v.fs
} }
@@ -217,6 +137,7 @@ func (v *Vault) NumSecrets() (int, error) {
} }
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, _ := afero.DirExists(v.fs, secretsDir) exists, _ := afero.DirExists(v.fs, secretsDir)
if !exists { if !exists {
return 0, nil return 0, nil
@@ -229,6 +150,7 @@ func (v *Vault) NumSecrets() (int, error) {
// Count only directories that have a "current" version pointer file // Count only directories that have a "current" version pointer file
count := 0 count := 0
for _, entry := range entries { for _, entry := range entries {
if !entry.IsDir() { if !entry.IsDir() {
continue continue
@@ -237,6 +159,7 @@ func (v *Vault) NumSecrets() (int, error) {
// A valid secret has a "current" file pointing to the active version // A valid secret has a "current" file pointing to the active version
secretDir := filepath.Join(secretsDir, entry.Name()) secretDir := filepath.Join(secretsDir, entry.Name())
currentFile := filepath.Join(secretDir, "current") currentFile := filepath.Join(secretDir, "current")
exists, err := afero.Exists(v.fs, currentFile) exists, err := afero.Exists(v.fs, currentFile)
if err != nil { if err != nil {
continue // Skip directories we can't read continue // Skip directories we can't read
@@ -249,3 +172,103 @@ func (v *Vault) NumSecrets() (int, error) {
return count, nil 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")
ltIdentity, err := unlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term key via SE: %w", err)
}
return ltIdentity, nil
}
// Standard unlockers: get unlocker identity, then decrypt longterm.age
unlockerIdentity, err := unlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
encryptedLtPrivKey, err := afero.ReadFile(v.fs, encryptedLtPrivKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term private key: %w", err)
}
ltPrivKeyBuffer, err := secret.DecryptWithIdentity(
encryptedLtPrivKey, unlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltIdentity, nil
}

View File

@@ -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")
} }

View File

@@ -1,36 +1,75 @@
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)
})
t.Run("ListVaults", func(t *testing.T) {
testListVaults(t, fs)
})
t.Run("SelectVault", func(t *testing.T) {
testSelectVault(t, fs)
})
t.Run("SecretOperations", func(t *testing.T) {
testSecretOperations(t, fs)
})
t.Run("NumSecrets", func(t *testing.T) {
testNumSecrets(t, fs)
})
t.Run("UnlockerOperations", func(t *testing.T) {
testUnlockerOperations(t, fs)
})
}
func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper()
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)
} }
if vlt.GetName() != "test-vault" { if vlt.GetName() != testVaultName {
t.Errorf("Expected vault name 'test-vault', got '%s'", vlt.GetName()) t.Errorf("Expected vault name '%s', got '%s'", testVaultName, vlt.GetName())
} }
// Check vault directory exists // Check vault directory exists
@@ -47,49 +86,45 @@ func TestVaultOperations(t *testing.T) {
if !exists { if !exists {
t.Errorf("Vault directory should exist") t.Errorf("Vault directory should exist")
} }
}) }
// Test vault listing func testListVaults(t *testing.T, fs afero.Fs) {
t.Run("ListVaults", func(t *testing.T) { t.Helper()
vaults, err := ListVaults(fs, stateDir)
vaults, err := vault.ListVaults(fs, testStateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to list vaults: %v", err) t.Fatalf("Failed to list vaults: %v", err)
} }
found := false if !slices.Contains(vaults, testVaultName) {
for _, vault := range vaults { t.Errorf("Expected to find '%s' in vault list", testVaultName)
if vault == "test-vault" {
found = true
break
} }
} }
if !found { func testSelectVault(t *testing.T, fs afero.Fs) {
t.Errorf("Expected to find 'test-vault' in vault list") t.Helper()
}
})
// Test vault selection err := vault.SelectVault(fs, testStateDir, testVaultName)
t.Run("SelectVault", func(t *testing.T) {
err := SelectVault(fs, stateDir, "test-vault")
if err != nil { if err != nil {
t.Fatalf("Failed to select vault: %v", err) t.Fatalf("Failed to select vault: %v", err)
} }
// Test getting current vault // Test getting current vault
currentVault, err := GetCurrentVault(fs, stateDir) currentVault, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to get current vault: %v", err) t.Fatalf("Failed to get current vault: %v", err)
} }
if currentVault.GetName() != "test-vault" { if currentVault.GetName() != testVaultName {
t.Errorf("Expected current vault 'test-vault', got '%s'", currentVault.GetName()) t.Errorf("Expected current vault '%s', got '%s'",
testVaultName, currentVault.GetName())
}
} }
})
// Test secret operations func testSecretOperations(t *testing.T, fs afero.Fs) {
t.Run("SecretOperations", func(t *testing.T) { t.Helper()
vlt, err := GetCurrentVault(fs, stateDir)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to get current vault: %v", err) t.Fatalf("Failed to get current vault: %v", err)
} }
@@ -111,6 +146,7 @@ func TestVaultOperations(t *testing.T) {
// Write the correct public key to the pub.age file // Write the correct public key to the pub.age file
pubKeyPath := filepath.Join(vaultDir, "pub.age") pubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms) err = afero.WriteFile(fs, pubKeyPath, []byte(ltPublicKey), secret.FilePerms)
if err != nil { if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err) t.Fatalf("Failed to write long-term public key: %v", err)
@@ -139,15 +175,7 @@ func TestVaultOperations(t *testing.T) {
t.Fatalf("Failed to list secrets: %v", err) t.Fatalf("Failed to list secrets: %v", err)
} }
found := false if !slices.Contains(secrets, secretName) {
for _, secret := range secrets {
if secret == secretName {
found = true
break
}
}
if !found {
t.Errorf("Expected to find secret '%s' in list", secretName) t.Errorf("Expected to find secret '%s' in list", secretName)
} }
@@ -158,13 +186,15 @@ func TestVaultOperations(t *testing.T) {
} }
if string(retrievedValue) != string(expectedValue) { if string(retrievedValue) != string(expectedValue) {
t.Errorf("Expected secret value '%s', got '%s'", string(expectedValue), string(retrievedValue)) t.Errorf("Expected secret value '%s', got '%s'",
string(expectedValue), string(retrievedValue))
}
} }
})
// Test NumSecrets func testNumSecrets(t *testing.T, fs afero.Fs) {
t.Run("NumSecrets", func(t *testing.T) { t.Helper()
vlt, err := GetCurrentVault(fs, stateDir)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to get current vault: %v", err) t.Fatalf("Failed to get current vault: %v", err)
} }
@@ -178,11 +208,12 @@ func TestVaultOperations(t *testing.T) {
if numSecrets != 1 { if numSecrets != 1 {
t.Errorf("Expected 1 secret, got %d", numSecrets) t.Errorf("Expected 1 secret, got %d", numSecrets)
} }
}) }
// Test unlocker operations func testUnlockerOperations(t *testing.T, fs afero.Fs) {
t.Run("UnlockerOperations", func(t *testing.T) { t.Helper()
vlt, err := GetCurrentVault(fs, stateDir)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
if err != nil { if err != nil {
t.Fatalf("Failed to get current vault: %v", err) t.Fatalf("Failed to get current vault: %v", err)
} }
@@ -196,8 +227,9 @@ func TestVaultOperations(t *testing.T) {
} }
// Create a passphrase unlocker // Create a passphrase unlocker
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase")) passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy() defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer) passphraseUnlocker, 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)
@@ -215,9 +247,11 @@ func TestVaultOperations(t *testing.T) {
// Check key type // Check key type
keyFound := false keyFound := false
for _, key := range unlockers { for _, key := range unlockers {
if key.Type == "passphrase" { if key.Type == "passphrase" {
keyFound = true keyFound = true
break break
} }
} }
@@ -239,7 +273,63 @@ func TestVaultOperations(t *testing.T) {
} }
if currentUnlocker.GetID() != passphraseUnlocker.GetID() { if currentUnlocker.GetID() != passphraseUnlocker.GetID() {
t.Errorf("Expected current unlocker ID '%s', got '%s'", passphraseUnlocker.GetID(), currentUnlocker.GetID()) t.Errorf("Expected current unlocker ID '%s', got '%s'",
passphraseUnlocker.GetID(), currentUnlocker.GetID())
}
}
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Use in-memory filesystem
fs := afero.NewMemMapFs()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Create a passphrase unlocker so we have at least one valid unlocker
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Create a bogus unlocker directory with no metadata file
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
bogusDir := filepath.Join(vaultDir, "unlockers.d", "bogus-no-metadata")
err = fs.MkdirAll(bogusDir, 0o700)
if err != nil {
t.Fatalf("Failed to create bogus directory: %v", err)
}
// ListUnlockers should succeed, skipping the bogus directory
unlockers, err := vlt.ListUnlockers()
if err != nil {
t.Fatalf("ListUnlockers returned error when it should have skipped "+
"bad directory: %v", err)
}
// Should still have the valid passphrase unlocker
if len(unlockers) == 0 {
t.Errorf("Expected at least one unlocker, got none")
}
// Verify we only got the valid unlocker(s), not the bogus one
for _, u := range unlockers {
if u.Type == "" {
t.Errorf("Got unlocker with empty type, likely from bogus directory")
}
} }
})
} }

View File

@@ -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

View File

@@ -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

155
script/bootstrap Executable file
View File

@@ -0,0 +1,155 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes NOTHING is present (not git,
# make, node, yarn, go, or python). Node is used directly if installed;
# otherwise a pinned version is installed via nvm (installing nvm
# itself first, from a hash-verified release archive, never curl | sh).
#
# Uncomment the language sections in main() that apply to this repo.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-06. Never "latest" or "lts"; exact versions.
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash, so install it too
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
# Base tooling (every repo)
if missing git; then pkg_install git git git git; fi
if missing make; then pkg_install gnumake make make make; fi
# ---- JS / docs repos ----
# ensure_node
# ensure_yarn
# install_js_deps
# ---- Go repos ----
if missing go; then pkg_install go golang go go; fi
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
# package: download a specific release archive from GitHub and
# verify its hash (verify_sha256), never curl | sh.
if missing golangci-lint; then
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
fi
go mod download
# ---- Python repos ----
# if missing python3; then pkg_install python3 python3 python3 python3; fi
# python3 -m venv .venv
# ./.venv/bin/pip install -e '.[dev]'
echo "bootstrap complete"
}
main "$@"

15
script/check Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

15
script/cibuild Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs script/check
# (via make check), so a successful build implies all checks pass.
# The Gitea workflow runs this on push. The memlock ulimit is required
# because the test suite uses memguard, which mlocks memory.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --ulimit memlock=-1:-1 .
}
main "$@"

15
script/docker Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# Generic: needs no adaptation.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

12
script/fmt Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
go fmt ./...
}
main "$@"

17
script/fmt-check Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then
echo "Files need formatting:"
gofmt -l .
exit 1
fi
}
main "$@"

17
script/install-precommit Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
# Generic: needs no adaptation.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

14
script/lint Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/lint: run the linter.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# CGO is required (Makefile exports this too)
export CGO_ENABLED=1
golangci-lint run --timeout 5m
}
main "$@"

20
script/precommit Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
# extras run first: go mod tidy and go fmt, failing the commit if they
# change go.mod or go.sum.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
go mod tidy
go fmt ./...
git diff --exit-code -- go.mod go.sum ||
{ echo "go mod tidy changed files; stage and retry" >&2; exit 1; }
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "secret"
}
main "$@"

14
script/setup Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies (script/bootstrap) and the git pre-commit hook.
# Add any repo-specific initialization (db init, .env template) here.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

15
script/test Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/sh
# script/test: run the test suite (vet first, verbose rerun on failure).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
# CGO is required (Makefile exports this too)
export CGO_ENABLED=1
go vet ./...
go test ./... || go test -v ./...
}
main "$@"