Compare commits

41 Commits

Author SHA1 Message Date
clawbot
efa8647166 fix: use make build instead of inline go build in Dockerfile
All checks were successful
check / check (push) Successful in 59s
REPO_POLICIES requires using Makefile targets instead of invoking
tools directly. Replace inline go build with make build.
2026-03-17 02:26:35 -07:00
clawbot
044ad92feb fix: add darwin build constraints to Objective-C source files
All checks were successful
check / check (push) Successful in 22s
Add //go:build darwin to secure_enclave.m and secure_enclave.h so Go
ignores them on non-darwin platforms. Without this, the lint stage fails
on Linux with 'Objective-C source files not allowed when not using cgo
or SWIG' because the !darwin stub (macse_stub.go) doesn't use CGO.
2026-03-14 17:54:41 -07:00
clawbot
386baaea70 fix: include .golangci.yml in Docker build context 2026-03-14 17:54:41 -07:00
clawbot
8edc629dd6 fix: add fmt-check to make check prerequisites
REPO_POLICIES requires make check prereqs to include test, lint,
and fmt-check.
2026-03-14 17:54:41 -07:00
clawbot
59839309b3 fix: use digest-only FROM syntax (no tags)
Remove tags from FROM lines — use image@sha256:digest only,
matching the upaas pattern. tag@sha256 syntax is invalid.
2026-03-14 17:54:41 -07:00
clawbot
66a390d685 fix: pin all Docker base images by SHA256 digest
Pin all three FROM lines with SHA256 digests per REPO_POLICIES.md:
- golangci/golangci-lint:v2.1.6@sha256:568ee1c1...
- golang:1.24-alpine@sha256:8bee1901...
- alpine:3.23@sha256:25109184... (was alpine:latest)

Also replaced mutable 'alpine:latest' tag with 'alpine:3.23'.
2026-03-14 17:54:41 -07:00
clawbot
7b84aa345f refactor: use official golangci-lint image for lint stage
Restructure Dockerfile to match upaas/dnswatcher pattern:
- Separate lint stage using golangci/golangci-lint:v2.1.6 image
- Builder stage for tests and compilation (no lint dependency)
- Add fmt-check Makefile target
- Decouple test from lint in Makefile (lint runs in its own stage)
- Run gofmt on all files
- docker build verified passing locally
2026-03-14 17:54:41 -07:00
clawbot
a8ce1ff7c8 fix: use correct checkout SHA and simplify CI workflow
The previous checkout SHA was invalid, causing immediate CI failure.
Use the known-good actions/checkout v4.2.2 SHA. Simplify trigger to
on: [push] to match other repos. Keep --ulimit memlock=-1:-1 for
10MB secret tests that need mlock.
2026-03-14 17:54:41 -07:00
user
afa4f799da fix: resolve CI failures in docker build
- Install golangci-lint v2 via binary download instead of go install
  (avoids Go 1.25 requirement of golangci-lint v2.10+)
- Add darwin build tags to tests that depend on macOS keychain:
  derivation_index_test.go, pgpunlock_test.go, validation (keychain tests)
- Move generateRandomString to helpers_darwin.go (only called from
  darwin-only keychainunlocker.go)
- Fix unchecked error returns flagged by errcheck linter
- Add gnupg to builder stage for PGP-related tests
- Use --ulimit memlock=-1:-1 in CI for memguard large secret tests
- Add //nolint:unused for intentionally kept but currently unused test helpers
2026-03-14 17:54:41 -07:00
user
9ada080821 ci: encapsulate checks in Dockerfile, simplify CI to docker build
Per new policy: CI actions simply run 'docker build .'. The Dockerfile
now installs golangci-lint and runs 'make check' early in the build
process, so a successful docker build implies all checks pass.

- Dockerfile: add golangci-lint install and 'make check' before final build
- CI workflow: simplify to just 'docker build .' (no Go setup needed)
- Makefile targets unchanged
2026-03-14 17:54:41 -07:00
25febccec1 security: pin all go install refs to commit SHAs 2026-03-14 17:54:41 -07:00
user
b68e1eb3d1 security: pin CI actions to commit SHAs 2026-03-14 17:54:41 -07:00
user
cbca2e59c5 ci: add Gitea Actions workflow for make check 2026-03-14 17:54:41 -07: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
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
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
50 changed files with 2237 additions and 444 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

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: docker build --ulimit memlock=-1:-1 .

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

@@ -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.1.6 (2026-03-10)
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b 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

@@ -17,7 +17,7 @@ build: ./secret
vet: vet:
go vet ./... go vet ./...
test: lint vet test: vet
go test ./... || go test -v ./... go test ./... || go test -v ./...
fmt: fmt:
@@ -26,7 +26,7 @@ fmt:
lint: lint:
golangci-lint run --timeout 5m golangci-lint run --timeout 5m
check: build test check: build lint test fmt-check
# Build Docker container # Build Docker container
docker: docker:
@@ -42,3 +42,6 @@ clean:
install: ./secret install: ./secret
cp ./secret $(HOME)/bin/secret cp ./secret $(HOME)/bin/secret
fmt-check:
@test -z "$$(gofmt -l .)" || (echo "Files need formatting:" && gofmt -l . && exit 1)

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
@@ -487,7 +488,7 @@ go test -tags=integration -v ./internal/cli # Integration tests
## 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

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,30 +17,30 @@ 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, err := secret.DetermineStateDir("") stateDir, err := secret.DetermineStateDir("")
if err != nil { if err != nil {
panic(fmt.Sprintf("cannot determine state directory: %v", err)) 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 filesystem (for testing)
func NewCLIInstanceWithFs(fs afero.Fs) *Instance { func NewCLIInstanceWithFs(fs afero.Fs) (*Instance, error) {
stateDir, err := secret.DetermineStateDir("") stateDir, err := secret.DetermineStateDir("")
if err != nil { if err != nil {
panic(fmt.Sprintf("cannot determine state directory: %v", err)) 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)

View File

@@ -25,7 +25,10 @@ func TestCLIInstanceStateDir(t *testing.T) {
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) cli, err := 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 := cli.GetStateDir()

View File

@@ -71,6 +71,8 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(fs, unlockersDir) files, err := afero.ReadDir(fs, unlockersDir)
if err != nil { if err != nil {
secret.Warn("Could not read unlockers directory during completion", "error", err)
continue continue
} }
@@ -85,11 +87,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
// Check if this is the right unlocker by comparing metadata // Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath) metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil { if err != nil {
secret.Warn("Could not read unlocker metadata during completion", "path", metadataPath, "error", err)
continue continue
} }
var diskMetadata secret.UnlockerMetadata var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
secret.Warn("Could not parse unlocker metadata during completion", "path", metadataPath, "error", err)
continue continue
} }

View File

@@ -22,7 +22,10 @@ func newEncryptCmd() *cobra.Command {
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 cli.Encrypt(args[0], inputFile, outputFile)
@@ -45,7 +48,10 @@ func newDecryptCmd() *cobra.Command {
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.Decrypt(args[0], inputFile, outputFile) return cli.Decrypt(args[0], inputFile, outputFile)

View File

@@ -38,7 +38,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)
}, },
@@ -56,7 +59,10 @@ func newGenerateSecretCmd() *cobra.Command {
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)
}, },

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
@@ -40,7 +41,10 @@ type InfoOutput struct {
// 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

View File

@@ -4,6 +4,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -28,6 +29,8 @@ func gatherVaultStats(
// Count secrets in this vault // Count secrets in this vault
secretEntries, err := afero.ReadDir(fs, secretsPath) secretEntries, err := afero.ReadDir(fs, secretsPath)
if err != nil { if err != nil {
secret.Warn("Could not read secrets directory for vault", "vault", vaultEntry.Name(), "error", err)
continue continue
} }
@@ -43,6 +46,8 @@ func gatherVaultStats(
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 {
secret.Warn("Could not read versions directory for secret", "secret", secretEntry.Name(), "error", err)
continue continue
} }

View File

@@ -2,6 +2,7 @@ package cli
import ( import (
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -27,7 +28,10 @@ func NewInitCmd() *cobra.Command {
// 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)
} }

View File

@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
code := m.Run() code := m.Run()
// Clean up the binary // Clean up the binary
os.Remove(filepath.Join(projectRoot, "secret")) _ = os.Remove(filepath.Join(projectRoot, "secret"))
os.Exit(code) os.Exit(code)
} }
@@ -450,10 +450,10 @@ func test02ListVaults(t *testing.T, runSecret func(...string) (string, error)) {
func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) { func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (string, error)) {
// Set environment variables for vault creation // Set environment variables for vault creation
os.Setenv("SB_SECRET_MNEMONIC", testMnemonic) _ = os.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase") _ = os.Setenv("SB_UNLOCK_PASSPHRASE", "test-passphrase")
defer os.Unsetenv("SB_SECRET_MNEMONIC") defer func() { _ = os.Unsetenv("SB_SECRET_MNEMONIC") }()
defer os.Unsetenv("SB_UNLOCK_PASSPHRASE") defer func() { _ = os.Unsetenv("SB_UNLOCK_PASSPHRASE") }()
// Create work vault // Create work vault
output, err := runSecret("vault", "create", "work") output, err := runSecret("vault", "create", "work")
@@ -489,6 +489,7 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
assert.Contains(t, output, "work", "should list work vault") assert.Contains(t, output, "work", "should list work vault")
} }
//nolint:unused // TODO: re-enable when vault import is implemented
func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
// Import mnemonic into work vault // Import mnemonic into work vault
output, err := runSecretWithEnv(map[string]string{ output, err := runSecretWithEnv(map[string]string{
@@ -1047,7 +1048,6 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
// Test invalid secret names // Test invalid secret names
invalidNames := []string{ invalidNames := []string{
"", // empty "", // empty
"UPPERCASE", // uppercase not allowed
"with space", // spaces not allowed "with space", // spaces not allowed
"with@symbol", // special characters not allowed "with@symbol", // special characters not allowed
"with#hash", // special characters not allowed "with#hash", // special characters not allowed
@@ -1073,7 +1073,7 @@ func test12SecretNameFormats(t *testing.T, tempDir, testMnemonic string, runSecr
// Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed) // Some of these might not be invalid after all (e.g., leading/trailing slashes might be stripped, .hidden might be allowed)
// For now, just check the ones we know should definitely fail // For now, just check the ones we know should definitely fail
definitelyInvalid := []string{"", "UPPERCASE", "with space", "with@symbol", "with#hash", "with$dollar"} definitelyInvalid := []string{"", "with space", "with@symbol", "with#hash", "with$dollar"}
shouldFail := false shouldFail := false
for _, invalid := range definitelyInvalid { for _, invalid := range definitelyInvalid {
if invalidName == invalid { if invalidName == invalid {
@@ -1668,9 +1668,9 @@ func test19DisasterRecovery(t *testing.T, tempDir, secretPath, testMnemonic stri
assert.Equal(t, testSecretValue, strings.TrimSpace(toolOutput), "tool output should match original") assert.Equal(t, testSecretValue, strings.TrimSpace(toolOutput), "tool output should match original")
// Clean up temporary files // Clean up temporary files
os.Remove(ltPrivKeyPath) _ = os.Remove(ltPrivKeyPath)
os.Remove(versionPrivKeyPath) _ = os.Remove(versionPrivKeyPath)
os.Remove(decryptedValuePath) _ = os.Remove(decryptedValuePath)
} }
func test20VersionTimestamps(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test20VersionTimestamps(t *testing.T, tempDir, secretPath, testMnemonic string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@@ -1789,7 +1789,7 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
// Add secret without mnemonic or unlocker // Add secret without mnemonic or unlocker
unsetMnemonic := os.Getenv("SB_SECRET_MNEMONIC") unsetMnemonic := os.Getenv("SB_SECRET_MNEMONIC")
os.Unsetenv("SB_SECRET_MNEMONIC") _ = os.Unsetenv("SB_SECRET_MNEMONIC")
cmd := exec.Command(secretPath, "add", "test/nomnemonic") cmd := exec.Command(secretPath, "add", "test/nomnemonic")
cmd.Env = []string{ cmd.Env = []string{
fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir), fmt.Sprintf("SB_SECRET_STATE_DIR=%s", tempDir),
@@ -2129,7 +2129,7 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
versionsPath := filepath.Join(secretPath, "versions") versionsPath := filepath.Join(secretPath, "versions")
if _, err := os.Stat(versionsPath); os.IsNotExist(err) { if _, err := os.Stat(versionsPath); os.IsNotExist(err) {
// This is a malformed secret directory, remove it // This is a malformed secret directory, remove it
os.RemoveAll(secretPath) _ = os.RemoveAll(secretPath)
} }
} }
} }
@@ -2179,7 +2179,7 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
require.NoError(t, err, "restore vaults should succeed") require.NoError(t, err, "restore vaults should succeed")
// Restore currentvault // Restore currentvault
os.Remove(currentVaultSrc) _ = os.Remove(currentVaultSrc)
restoredData := readFile(t, currentVaultDst) restoredData := readFile(t, currentVaultDst)
writeFile(t, currentVaultSrc, restoredData) writeFile(t, currentVaultSrc, restoredData)
@@ -2285,6 +2285,8 @@ func verifyFileExists(t *testing.T, path string) {
} }
// verifyFileNotExists checks if a file does not exist at the given path // verifyFileNotExists checks if a file does not exist at the given path
//
//nolint:unused // kept for future use
func verifyFileNotExists(t *testing.T, path string) { func verifyFileNotExists(t *testing.T, path string) {
t.Helper() t.Helper()
_, err := os.Stat(path) _, err := os.Stat(path)

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -44,7 +45,10 @@ func newAddCmd() *cobra.Command {
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")
@@ -58,7 +62,10 @@ 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 +73,10 @@ 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)
}, },
@@ -93,7 +103,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)
}, },
@@ -115,7 +128,10 @@ func newImportCmd() *cobra.Command {
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,7 +145,10 @@ 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"},
@@ -139,7 +158,10 @@ func newRemoveCmd() *cobra.Command {
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 +171,10 @@ 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"},
@@ -172,7 +197,10 @@ The source secret is deleted after successful copy.`,
}, },
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)
}, },
@@ -479,7 +507,7 @@ func (cli *Instance) ImportSecret(cmd *cobra.Command, secretName, sourceFile str
} }
defer func() { defer func() {
if err := file.Close(); err != nil { if err := file.Close(); err != nil {
secret.Debug("Failed to close file", "error", err) secret.Warn("Failed to close file", "error", err)
} }
}() }()

View File

@@ -113,7 +113,10 @@ func TestAddSecretVariousSizes(t *testing.T) {
cmd.SetIn(stdin) cmd.SetIn(stdin)
// 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 = stateDir
cli.cmd = cmd cli.cmd = cmd
@@ -230,7 +233,10 @@ func TestImportSecretVariousSizes(t *testing.T) {
cmd := &cobra.Command{} cmd := &cobra.Command{}
// 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 = stateDir
@@ -318,7 +324,10 @@ func TestAddSecretBufferGrowth(t *testing.T) {
cmd.SetIn(stdin) cmd.SetIn(stdin)
// 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 = stateDir
cli.cmd = cmd cli.cmd = cmd
@@ -377,7 +386,10 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
cmd.SetIn(slowReader) cmd.SetIn(slowReader)
// 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 = stateDir
cli.cmd = cmd cli.cmd = cmd

View File

@@ -3,6 +3,7 @@ package cli
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -96,7 +97,10 @@ func newUnlockerListCmd() *cobra.Command {
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)
@@ -123,7 +127,7 @@ func newUnlockerAddCmd() *cobra.Command {
Use --keyid to specify a particular key, otherwise uses your default GPG key.` Use --keyid to specify a particular key, otherwise uses your default GPG key.`
if runtime.GOOS == "darwin" { if runtime.GOOS == "darwin" {
supportedTypes = "passphrase, keychain, pgp" supportedTypes = "passphrase, keychain, pgp, secure-enclave"
typeDescriptions = `Available unlocker types: typeDescriptions = `Available unlocker types:
passphrase - Traditional password-based encryption passphrase - Traditional password-based encryption
@@ -138,7 +142,12 @@ func newUnlockerAddCmd() *cobra.Command {
pgp - GNU Privacy Guard (GPG) key-based encryption pgp - GNU Privacy Guard (GPG) key-based encryption
Uses your existing GPG key to encrypt/decrypt the vault's master key. Uses your existing GPG key to encrypt/decrypt the vault's master key.
Requires gpg to be installed and configured with at least one secret key. Requires gpg to be installed and configured with at least one secret key.
Use --keyid to specify a particular key, otherwise uses your default GPG key.` Use --keyid to specify a particular key, otherwise uses your default GPG key.
secure-enclave - Apple Secure Enclave hardware protection (macOS only)
Stores the vault's master key encrypted by a non-exportable P-256 key
held in the Secure Enclave. The key never leaves the hardware.
Uses ECIES encryption; decryption is performed inside the SE.`
} }
cmd := &cobra.Command{ cmd := &cobra.Command{
@@ -153,7 +162,10 @@ 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
@@ -186,7 +198,10 @@ to access the same vault. This provides flexibility and backup access options.`,
} }
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"},
@@ -198,7 +213,10 @@ func newUnlockerRemoveCmd() *cobra.Command {
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)
}, },
@@ -210,7 +228,10 @@ func newUnlockerRemoveCmd() *cobra.Command {
} }
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,7 +239,10 @@ 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])
}, },
@@ -252,6 +276,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// 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
} }
@@ -259,6 +285,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir) files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil { if err != nil {
secret.Warn("Could not read unlockers directory", "error", err)
continue continue
} }
@@ -274,12 +302,16 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Check if this is the right unlocker by comparing metadata // Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath) metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil { if err != nil {
continue // FIXME this error needs to be handled secret.Warn("Could not read unlocker metadata file", "path", metadataPath, "error", err)
continue
} }
var diskMetadata secret.UnlockerMetadata var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
continue // FIXME this error needs to be handled secret.Warn("Could not parse unlocker metadata file", "path", metadataPath, "error", err)
continue
} }
// Match by type and creation time // Match by type and creation time
@@ -292,6 +324,8 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata) unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp": case "pgp":
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata) unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
case "secure-enclave":
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
} }
break break
@@ -305,6 +339,7 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
} 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{
@@ -382,7 +417,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
// 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 == "darwin" {
supportedTypes = "passphrase, keychain, pgp" supportedTypes = "passphrase, keychain, pgp, secure-enclave"
} }
switch unlockerType { switch unlockerType {
@@ -453,6 +488,31 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
return nil return nil
case "secure-enclave":
if runtime.GOOS != "darwin" {
return fmt.Errorf("secure enclave unlockers are only supported on macOS")
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
}
cmd.Printf("Created Secure Enclave unlocker: %s\n", seUnlocker.GetID())
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
if err := vlt.SelectUnlocker(seUnlocker.GetID()); err != nil {
cmd.Printf("Warning: Failed to auto-select new unlocker: %v\n", err)
} else {
cmd.Printf("Automatically selected as current unlocker\n")
}
return nil
case "pgp": case "pgp":
// 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
@@ -571,12 +631,16 @@ 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
} }
@@ -586,6 +650,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
files, err := afero.ReadDir(cli.fs, unlockersDir) files, err := afero.ReadDir(cli.fs, unlockersDir)
if err != nil { if err != nil {
secret.Warn("Could not read unlockers directory during duplicate check", "error", err)
continue continue
} }
@@ -600,11 +666,15 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
// Check if this matches our metadata // Check if this matches our metadata
metadataBytes, err := afero.ReadFile(cli.fs, metadataPath) metadataBytes, err := afero.ReadFile(cli.fs, metadataPath)
if err != nil { if err != nil {
secret.Warn("Could not read unlocker metadata during duplicate check", "path", metadataPath, "error", err)
continue continue
} }
var diskMetadata secret.UnlockerMetadata var diskMetadata secret.UnlockerMetadata
if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil { if err := json.Unmarshal(metadataBytes, &diskMetadata); err != nil {
secret.Warn("Could not parse unlocker metadata during duplicate check", "path", metadataPath, "error", err)
continue continue
} }
@@ -618,6 +688,8 @@ func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) er
unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata) unlocker = secret.NewKeychainUnlocker(cli.fs, unlockerDir, diskMetadata)
case "pgp": case "pgp":
unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata) unlocker = secret.NewPGPUnlocker(cli.fs, unlockerDir, diskMetadata)
case "secure-enclave":
unlocker = secret.NewSecureEnclaveUnlocker(cli.fs, unlockerDir, diskMetadata)
} }
if unlocker != nil && unlocker.GetID() == unlockerID { if unlocker != nil && unlocker.GetID() == unlockerID {

View File

@@ -3,6 +3,7 @@ package cli
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -41,7 +42,10 @@ func newVaultListCmd() *cobra.Command {
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 +62,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 +73,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 +84,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,7 +95,10 @@ 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>",
@@ -96,7 +112,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,7 +123,10 @@ 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"},
@@ -115,7 +137,10 @@ func newVaultRemoveCmd() *cobra.Command {
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)
}, },

View File

@@ -2,6 +2,7 @@ package cli
import ( import (
"fmt" "fmt"
"log"
"path/filepath" "path/filepath"
"strings" "strings"
"text/tabwriter" "text/tabwriter"
@@ -18,7 +19,10 @@ const (
// 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)
} }
@@ -160,7 +164,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
// Load metadata // Load metadata
if err := sv.LoadMetadata(ltIdentity); err != nil { if err := sv.LoadMetadata(ltIdentity); err != nil {
secret.Debug("Failed to load version metadata", "version", version, "error", err) 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 {

View File

@@ -266,7 +266,10 @@ func TestGetSecretWithVersion(t *testing.T) {
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)

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,29 @@
//go:build !darwin
// +build !darwin
// Package macse provides Go bindings for macOS Secure Enclave operations.
package macse
import "fmt"
var errNotSupported = fmt.Errorf("secure enclave is only supported on macOS") //nolint:gochecknoglobals
// 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

@@ -58,6 +58,16 @@ 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) {
output := fmt.Sprintf("WARNING: %s", msg)
for i := 0; i+1 < len(args); i += 2 {
output += fmt.Sprintf(" %s=%v", args[i], args[i+1])
}
output += "\n"
fmt.Fprint(os.Stderr, output)
}
// 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 {

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,33 +1,11 @@
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
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
}
// DetermineStateDir determines the state directory based on environment variables and OS. // DetermineStateDir determines the state directory based on environment variables and OS.
// It returns an error if no usable directory can be determined. // It returns an error if no usable directory can be determined.
func DetermineStateDir(customConfigDir string) (string, error) { func DetermineStateDir(customConfigDir string) (string, error) {
@@ -53,7 +31,10 @@ func DetermineStateDir(customConfigDir string) (string, error) {
return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr) return "", fmt.Errorf("unable to determine state directory: config dir: %w, home dir: %w", err, homeErr)
} }
return filepath.Join(homeDir, ".config", AppID), nil 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 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

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

@@ -24,7 +24,7 @@ func TestPassphraseUnlockerWithRealFS(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
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
@@ -155,7 +155,7 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
}) })
// 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 prompt)
// For real integration tests, we'd need to provide a way to mock the passphrase input // For real integration tests, we'd need to provide a way to mock the passphrase input

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

@@ -257,9 +257,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
@@ -283,7 +284,9 @@ 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
} }

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,84 @@
//go:build !darwin
// +build !darwin
package secret
import (
"fmt"
"filippo.io/age"
"github.com/spf13/afero"
)
var errSENotSupported = fmt.Errorf(
"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
}
// 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 "secure-enclave"
}
// 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 fmt.Sprintf(
"%s-secure-enclave",
s.Metadata.CreatedAt.Format("2006-01-02.15.04"),
)
}
// Remove returns an error on non-Darwin platforms.
func (s *SecureEnclaveUnlocker) Remove() error {
return errSENotSupported
}
// NewSecureEnclaveUnlocker creates a stub SecureEnclaveUnlocker on non-Darwin platforms.
// The returned instance's methods that require macOS functionality will return errors.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// CreateSecureEnclaveUnlocker returns an error on non-Darwin platforms.
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}

View File

@@ -0,0 +1,90 @@
//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, "secure-enclave", 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) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
identity, err := unlocker.GetIdentity()
assert.Nil(t, identity)
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestSecureEnclaveUnlockerRemoveReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{
Type: "secure-enclave",
CreatedAt: time.Now().UTC(),
}
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
err := unlocker.Remove()
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
assert.Nil(t, unlocker)
assert.Error(t, err)
assert.ErrorIs(t, err, errSENotSupported)
}
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 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

@@ -154,144 +154,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

@@ -102,6 +102,8 @@ 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 { if _, err := fmt.Sscanf(parts[1], "%03d", &serial); err != nil {
Warn("Skipping malformed version directory name", "name", entry.Name(), "error", err)
continue continue
} }

View File

@@ -0,0 +1,96 @@
package vault
import (
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// 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
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
fs := afero.NewMemMapFs()
stateDir := "/test/state"
vlt, err := CreateVault(fs, stateDir, "test-vault")
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, "")
assert.Error(t, err, "GetSecretVersion should reject malicious name: %s", name)
assert.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) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
fs := afero.NewMemMapFs()
stateDir := "/test/state"
vlt, err := CreateVault(fs, stateDir, "test-vault")
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid secret name")
}
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts.
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, "test-passphrase")
fs := afero.NewMemMapFs()
stateDir := "/test/state"
vlt, err := CreateVault(fs, stateDir, "test-vault")
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)
assert.Error(t, err, "GetSecretObject should reject: %s", name)
assert.Contains(t, err.Error(), "invalid secret name")
})
}
}

View File

@@ -67,7 +67,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 +92,15 @@ func isValidSecretName(name string) bool {
return false return false
} }
// Check for path traversal via ".." components
for _, part := range strings.Split(name, "/") {
if part == ".." {
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
} }
@@ -319,6 +326,13 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
slog.String("version", version), slog.String("version", version),
) )
// Validate secret name to prevent path traversal
if !isValidSecretName(name) {
secret.Debug("Invalid secret name provided", "secret_name", name)
return nil, fmt.Errorf("invalid secret name '%s': must match pattern [a-z0-9.\\-_/]+", name)
}
// Get vault directory // Get vault directory
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
@@ -454,6 +468,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("invalid secret name: %s", 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 {

View File

@@ -0,0 +1,42 @@
package vault
import "testing"
func TestIsValidSecretNameUppercase(t *testing.T) {
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) {
result := isValidSecretName(tt.name)
if result != tt.valid {
t.Errorf("isValidSecretName(%q) = %v, want %v", tt.name, result, tt.valid)
}
})
}
}

View File

@@ -83,6 +83,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
case "keychain": case "keychain":
secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type) secret.Debug("Creating keychain unlocker instance", "unlocker_type", metadata.Type)
unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata) unlocker = secret.NewKeychainUnlocker(v.fs, unlockerDir, metadata)
case "secure-enclave":
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)
@@ -166,6 +169,8 @@ func (v *Vault) findUnlockerByID(unlockersDir, unlockerID string) (secret.Unlock
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata) tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
case "keychain": case "keychain":
tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata) tempUnlocker = secret.NewKeychainUnlocker(v.fs, unlockerDirPath, metadata)
case "secure-enclave":
tempUnlocker = secret.NewSecureEnclaveUnlocker(v.fs, unlockerDirPath, metadata)
default: default:
continue continue
} }
@@ -213,7 +218,9 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
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)

View File

@@ -129,55 +129,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",
@@ -194,6 +151,47 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return ltIdentity, nil return ltIdentity, nil
} }
// unlockLongTermKey extracts the vault's long-term key using the given unlocker.
// 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() == "secure-enclave" {
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
}
// GetDirectory returns the vault's directory path // GetDirectory returns the vault's directory path
func (v *Vault) GetDirectory() (string, error) { func (v *Vault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.Name), nil return filepath.Join(v.stateDir, "vaults.d", v.Name), nil

View File

@@ -243,3 +243,57 @@ func TestVaultOperations(t *testing.T) {
} }
}) })
} }
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// 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.EnvUnlockPassphrase, "test-passphrase")
// Use in-memory filesystem
fs := afero.NewMemMapFs()
stateDir := "/test/state"
// Create vault
vlt, err := CreateVault(fs, stateDir, "test-vault")
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("test-passphrase"))
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")
}
}
}