Author SHA1 Message Date
clawbot db7d2c952e Read secret environment variables once per command, then unset them (closes #60)
check / check (push) Waiting to run
init and vault create put the mnemonic into the process environment for
vault.CreateVault to read back, so every program they ran, gpg included,
inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read
at 13 places and never unset. Each command that may need them now reads
both once, in its RunE, into locked buffers on the CLI Instance, and
unsets them at once. The buffers are passed down: vault.CreateVault
takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and
the PGP, keychain and Secure Enclave unlocker constructors take both;
CreatePGPUnlocker sets them on the vault it loads through SetMnemonic
and SetUnlockPassphrase, new in VaultInterface. README warns against
both variables.

Model: opus-5-5
2026-10-04 16:07:57 +02:00
clawbot 5ca615a7a6 Use the canonical golangci-lint config (closes #66)
check / check (push) Waiting to run
Replace `.golangci.yml` with the canonical file from `sneak/prompts`,
byte for byte. It runs `gomodguard_v2` in place of the deprecated
`gomodguard`, which removes the deprecation warning from every lint run,
and enables a `depguard` rule keeping `net/http/httptest` out of non-test
files. Neither raised a finding in this repo.

Model: opus-5-5
2026-10-04 15:32:21 +02:00
clawbot 62967f28d0 Make secret unlocker add pgp work on Linux (closes #88)
check / check (push) Waiting to run
CreatePGPUnlocker got the vault's long-term key from the keychain
unlocker's helper, which on every platform but macOS is a stub that
always fails. It now calls the vault's GetOrDeriveLongTermKey, as adding
a passphrase unlocker does: from the mnemonic, checked against the
vault, or else from the current unlocker. That method joins
VaultInterface. The test GPG key gains an encryption subkey, and a new
test adds a PGP unlocker with the long-term key from the mnemonic and
from a passphrase unlocker, then reads a secret through it.

Model: opus-5-5
2026-10-04 14:42:01 +02:00
55 changed files with 1332 additions and 1159 deletions
+66 -2
View File
@@ -10,14 +10,20 @@ run:
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -28,6 +34,64 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
+1 -4
View File
@@ -14,11 +14,8 @@ ARG CHECK_EPOCH
COPY . . COPY . .
RUN make fmt-check RUN make fmt-check
# Not make lint or make lint-darwin: script/lint and script/lint-darwin are # Not make lint: script/lint is a docker build, which cannot run in here.
# docker builds, which cannot run in here. These are their commands.
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation # Build stage — tests and compilation
# golang 1.24.13-alpine (2026-03-10) # golang 1.24.13-alpine (2026-03-10)
+3 -13
View File
@@ -1,6 +1,6 @@
# Lint image, built by script/lint and script/lint-darwin: golangci-lint runs # Lint image, built by script/lint: golangci-lint runs as a build step, so a
# as a build step, so a successful build is a clean lint. Works where the # successful build is a clean lint. Works where the docker daemon is remote
# docker daemon is remote and bind mounts are impossible. # and bind mounts are impossible.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
@@ -17,13 +17,3 @@ FROM deps AS lint
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# script/lint-darwin rebuilds this stage on every run, by this name. It
# checks the code as a macOS build compiles it, but with cgo off, which
# leaves out the files that need cgo on macOS (see script/lint-darwin).
FROM deps AS lint-darwin
COPY . .
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
+2 -6
View File
@@ -1,7 +1,7 @@
export CGO_ENABLED=1 export CGO_ENABLED=1
.PHONY: default bootstrap setup build test lint lint-darwin fmt fmt-check \ .PHONY: default bootstrap setup build test lint fmt fmt-check check docker \
check docker docker-run clean install hooks docker-run clean install hooks
default: check default: check
@@ -24,10 +24,6 @@ fmt:
lint: lint:
@script/lint @script/lint
# Type-check and lint the macOS build from Linux (see script/lint-darwin)
lint-darwin:
@script/lint-darwin
check: check:
@script/check @script/check
+14 -7
View File
@@ -318,6 +318,18 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt) - `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers - `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
**Warning:** `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` expose the secret
they hold. Other processes running as the same user can read a process's
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
shell or script that sets them inherits them, `gpg` included. Set on a command
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
each one as soon as it has read it, so that the programs it runs itself, such
as `gpg`, do not inherit it, but that erases nothing: the environment the
process started with, and its memory, still hold the value. The interactive
prompt, which every command except `secret vault import` offers when the
variable is not set, is the safer default; `secret vault import` has no prompt
and needs both variables.
## Security Features ## Security Features
### Encryption ### Encryption
@@ -519,15 +531,10 @@ them. We provide:
- `script/lint` — run `golangci-lint` in docker only: builds - `script/lint` — run `golangci-lint` in docker only: builds
`Dockerfile.lint`, where the linter is a build step that runs on every `Dockerfile.lint`, where the linter is a build step that runs on every
call, also on an unchanged tree call, also on an unchanged tree
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on
the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
build never compiles; cgo is off, so the keychain unlocker
(`internal/secret/keychainunlocker.go` and its tests) and the Secure
Enclave bindings (`internal/macse`) are not checked
- `script/fmt` — format all Go code (writes) - `script/fmt` — format all Go code (writes)
- `script/fmt-check` — check formatting without writing - `script/fmt-check` — check formatting without writing
- `script/check` — run `script/test`, `script/lint`, - `script/check` — run `script/test`, `script/lint`, and
`script/lint-darwin`, and `script/fmt-check` `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: `docker build --ulimit - `script/cibuild` — CI entrypoint: `docker build --ulimit
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
+34 -27
View File
@@ -25,22 +25,35 @@ Bring the repo into policy compliance in one commit:
# Completed Steps # Completed Steps
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
`golangci-lint` in docker on the code as a macOS build compiles it per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
(`GOOS=darwin`), with cgo off unset at once, so that no program the command runs, `gpg` included,
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
and the `Dockerfile` lint stage runs its commands, so `script/cibuild` the command reads the environment; the buffers are passed down:
does too. Before, CI on Linux never compiled the files built only for `vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
macOS. Compiling cgo code for macOS needs Apple's SDK headers, and both long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
`internal/macse` and `github.com/keybase/go-keychain`, which passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
`keychainunlocker.go` uses, are cgo on macOS. So `keychainunlocker.go` constructors take both. `CreatePGPUnlocker` sets both on the vault it
and its tests are now built only with cgo on macOS, like loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
`macse_darwin.go`, and the keychain and `macse` stubs serve a macOS `VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
build without cgo, which before did not compile. `checkMacOSAvailable` `vault create` no longer put the mnemonic into the environment. Unsetting
moved to `seunlocker_darwin.go`. The check covers the Secure Enclave erases nothing: the starting environment (`/proc/<pid>/environ`) and
unlocker and the macOS-only tests `seunlocker_test.go` and memory still hold the value. The README warns against both variables.
`pgpunlock_test.go`, whose lint findings are fixed; lines over 88 - 2026-10-04: `.golangci.yml` is again the canonical file from
columns in the macOS files it cannot check are wrapped. `sneak/prompts`, byte for byte
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
in place of the deprecated `gomodguard`, so the lint no longer warns,
and enables `depguard` with a rule that keeps `net/http/httptest` out of
non-test files. Neither raised a finding in this repo.
- 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
the vault's long-term key as adding a passphrase unlocker does, with the
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
mnemonic, checked against the vault, or else from the current unlocker.
Before, it used the keychain unlocker's helper, which on every platform
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
key, getting the long-term key once from the mnemonic and once from a
passphrase unlocker, and reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, - 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
`.`, `-` and `_`, and must not be empty, `.` or `..` `.`, `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` (https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
@@ -264,15 +277,11 @@ Bring the repo into policy compliance in one commit:
- Cover mnemonic-vs-xprv identity consistency in - Cover mnemonic-vs-xprv identity consistency in
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an `pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
in-code FIXME removed for godox). in-code FIXME removed for godox).
- CI does not compile, lint or test the files built only with cgo on - Darwin-gated files (`internal/secret/keychainunlocker.go`,
macOS, since compiling them needs Apple's SDK: `seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related
`internal/secret/keychainunlocker.go` with `keychainunlocker_test.go`, tests) are not linted on the Linux CI runner and still contain lines
`validation_darwin_test.go` and `derivation_index_test.go`, and over the new 88-column limit; they will surface if lint ever runs on
`internal/macse` (`macse_darwin.go`, `macse_test.go`, the Objective-C macOS.
sources). Lint has never run on them, so it would likely find more
there than the line lengths. No macOS test runs in CI. A macOS runner
would cover all of it (asked on
https://git.eeqj.de/sneak/secret/issues/50).
- Merge secure-enclave-unlocker to main once review is done. - Merge secure-enclave-unlocker to main once review is done.
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Command injection: GPG key IDs passed unescaped to exec.Command - Command injection: GPG key IDs passed unescaped to exec.Command
@@ -298,8 +307,6 @@ Bring the repo into policy compliance in one commit:
suggestions. suggestions.
- Validate GPG key existence before creating PGP unlock keys. - Validate GPG key existence before creating PGP unlock keys.
- Split oversized CLI functions. - Split oversized CLI functions.
- Document env var security (SB_UNLOCK_PASSPHRASE,
SB_SECRET_MNEMONIC); clear after use.
- mlock/munlock for sensitive allocations. - mlock/munlock for sensitive allocations.
- Cleanups: read statedir from environment or default instead of - Cleanups: read statedir from environment or default instead of
passing it around. passing it around.
+47
View File
@@ -3,8 +3,10 @@ package cli
import ( import (
"fmt" "fmt"
"os"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -14,6 +16,11 @@ type Instance struct {
fs afero.Fs fs afero.Fs
stateDir string stateDir string
cmd *cobra.Command cmd *cobra.Command
// Mnemonic and UnlockPassphrase hold the values of SB_SECRET_MNEMONIC
// and SB_UNLOCK_PASSPHRASE that readSecretEnv read, or nil when it found
// none.
Mnemonic *memguard.LockedBuffer
UnlockPassphrase *memguard.LockedBuffer
} }
// NewCLIInstance creates a new CLI instance with the real filesystem // NewCLIInstance creates a new CLI instance with the real filesystem
@@ -68,3 +75,43 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string { func (cli *Instance) GetStateDir() string {
return cli.stateDir return cli.stateDir
} }
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
// either calls it once, before anything else, and passes the buffers on
// from there: each variable is unset as soon as it is read, so that the
// processes this one starts, gpg among them, do not inherit it, and a
// second read would find nothing. The returned function destroys both
// buffers.
func (cli *Instance) readSecretEnv() func() {
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
return func() {
if mnemonic != nil {
mnemonic.Destroy()
}
if passphrase != nil {
passphrase.Destroy()
}
}
}
// readAndUnsetEnv returns the value of the environment variable name in a
// locked buffer, or nil when it is unset or empty, and unsets the variable.
// Unsetting does not erase the value: it stays in this process's memory,
// and in /proc/<pid>/environ, which shows the environment the process
// started with. The caller must destroy the returned buffer.
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
value := os.Getenv(name)
_ = os.Unsetenv(name)
if value == "" {
return nil
}
return memguard.NewBufferFromBytes([]byte(value))
}
+58 -16
View File
@@ -2,6 +2,7 @@ package cli_test
import ( import (
"bytes" "bytes"
"os"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
@@ -20,16 +21,27 @@ import (
// decrypted any more. Each must refuse, change nothing, and leave every // decrypted any more. Each must refuse, change nothing, and leave every
// vault's secret readable through its passphrase unlocker. // vault's secret readable through its passphrase unlocker.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // the cases share cmd
func TestCreateExistingVaultChangesNothing(t *testing.T) { func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
// newCLI returns an instance on fs given the mnemonic and the unlock
// passphrase, as from the environment
newCLI := func(fs afero.Fs) *cli.Instance {
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return c
}
// `secret init`, `secret vault create work`, `secret vault select // `secret init`, `secret vault create work`, `secret vault select
// default`, and the secret "x" in each vault. "work" is then not the // default`, and the secret "x" in each vault. "work" is then not the
// current vault, which creating it again must not change. // current vault, which creating it again must not change.
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := newCLI(fs)
cmd := &cobra.Command{} cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd)) require.NoError(t, c.Init(cmd))
@@ -74,7 +86,7 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before) fs := newFsFromSnapshot(t, before)
err := tt.run(cli.NewCLIInstanceWithStateDir(fs, testStateDir)) err := tt.run(newCLI(fs))
require.EqualError(t, err, tt.want) require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
@@ -85,10 +97,11 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
// reading each vault's secret once from it shows that it still decrypts // reading each vault's secret once from it shows that it still decrypts
// after each case. Without the mnemonic, reading a secret goes through // after each case. Without the mnemonic, reading a secret goes through
// the vault's passphrase unlocker, which is slow. // the vault's passphrase unlocker, which is slow.
t.Setenv(secret.EnvMnemonic, "")
for _, name := range vaults { for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x") vlt := vault.NewVault(fs, testStateDir, name)
vlt.UnlockPassphrase = passphrase
value, err := vlt.GetSecret("x")
require.NoError(t, err) require.NoError(t, err)
unchanged := bytes.Equal([]byte("value"), value.Bytes()) unchanged := bytes.Equal([]byte("value"), value.Bytes())
@@ -98,19 +111,43 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
} }
} }
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
// `secret vault create` put the mnemonic into the process environment,
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
// leave neither in the environment.
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
t.Setenv(secret.EnvStateDir, t.TempDir())
run := func(args ...string) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// With no terminal to prompt on, this succeeds only if the command
// read both variables
_, err := cli.ExecuteCommandInProcess(args, "", nil)
require.NoError(t, err)
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
_, set := os.LookupEnv(name)
require.False(t, set, "%s is set after %v", name, args)
}
}
run("init")
run("vault", "create", "work")
}
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the // TestStopAtPassphrasePromptLeavesNothing is a regression test for the
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or // review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
// `secret vault create` stopped at the passphrase prompt left a vault with // `secret vault create` stopped at the passphrase prompt left a vault with
// no unlocker, which neither command would then create again. Each must ask // no unlocker, which neither command would then create again. Each must ask
// for the passphrase before writing anything. // for the passphrase before writing anything.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // the cases share cmd
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) { func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
// Without the passphrase in the environment, both commands prompt for
// it, which fails because the tests do not run in a terminal.
t.Setenv(secret.EnvUnlockPassphrase, "")
// An empty state directory for `secret init`, and one holding the vault // An empty state directory for `secret init`, and one holding the vault
// "default" for `secret vault create work`. // "default" for `secret vault create work`.
@@ -118,7 +155,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms)) require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs() withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default") _, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
require.NoError(t, err) require.NoError(t, err)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -144,7 +181,12 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
before := snapshotStateDir(t, tt.fs) before := snapshotStateDir(t, tt.fs)
err := tt.run(cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)) // Given no unlock passphrase, both commands prompt for it, which
// fails because the tests do not run in a terminal.
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
c.Mnemonic = mnemonic
err := tt.run(c)
require.ErrorContains(t, err, "failed to read passphrase") require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs)) require.Equal(t, before, snapshotStateDir(t, tt.fs))
+12 -5
View File
@@ -41,6 +41,9 @@ func newCryptoCmd(
cli.cmd = cmd cli.cmd = cmd
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return run(cli, args[0], inputFile, outputFile) return run(cli, args[0], inputFile, outputFile)
}, },
} }
@@ -156,6 +159,8 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get or create the age secret key for this secret // Get or create the age secret key for this secret
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName) keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
if err != nil { if err != nil {
@@ -230,6 +235,8 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if secret exists // Check if secret exists
secretObj := secret.NewSecret(vlt, secretName) secretObj := secret.NewSecret(vlt, secretName)
@@ -308,13 +315,13 @@ func isValidAgeSecretKey(key string) bool {
return err == nil return err == nil
} }
// getSecretValue retrieves the value of a secret using the appropriate // getSecretValue retrieves the value of a secret with the vault's mnemonic
// unlocker // when it has one, else with the current unlocker
func (cli *Instance) getSecretValue( func (cli *Instance) getSecretValue(
vlt *vault.Vault, secretObj *secret.Secret, vlt *vault.Vault, secretObj *secret.Secret,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
if os.Getenv(secret.EnvMnemonic) != "" { if vlt.Mnemonic != nil {
return secretObj.GetValue(nil) return secretObj.GetValue(nil, vlt.Mnemonic)
} }
unlocker, err := vlt.GetCurrentUnlocker() unlocker, err := vlt.GetCurrentUnlocker()
@@ -322,5 +329,5 @@ func (cli *Instance) getSecretValue(
return nil, fmt.Errorf("failed to get current unlocker: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
return secretObj.GetValue(unlocker) return secretObj.GetValue(unlocker, nil)
} }
+5
View File
@@ -76,6 +76,9 @@ func newGenerateSecretCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.GenerateSecret(cmd, args[0], length, secretType, force) return cli.GenerateSecret(cmd, args[0], length, secretType, force)
}, },
} }
@@ -167,6 +170,8 @@ func (cli *Instance) GenerateSecret(
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Protect the generated secret immediately // Protect the generated secret immediately
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue)) secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
defer secretBuffer.Destroy() defer secretBuffer.Destroy()
+19 -18
View File
@@ -39,16 +39,20 @@ func RunInit(cmd *cobra.Command, _ []string) error {
log.Fatalf("failed to initialize CLI: %v", err) log.Fatalf("failed to initialize CLI: %v", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.Init(cmd) return cli.Init(cmd)
} }
// promptMnemonic reads the mnemonic from the environment or interactively. // promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
// The returned cleanup function must be deferred by the caller. // or reads it interactively. The returned cleanup function must be deferred
func promptMnemonic() (string, func(), error) { // by the caller.
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" { func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
if cli.Mnemonic != nil {
secret.Debug("Using mnemonic from environment variable") secret.Debug("Using mnemonic from environment variable")
return envMnemonic, func() {}, nil return cli.Mnemonic, func() {}, nil
} }
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
@@ -58,23 +62,23 @@ func promptMnemonic() (string, func(), error) {
if err != nil { if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err) secret.Debug("Failed to read mnemonic from stdin", "error", err)
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err) return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
} }
fmt.Fprintln(os.Stderr) // Add newline after hidden input fmt.Fprintln(os.Stderr) // Add newline after hidden input
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil return mnemonicBuffer, mnemonicBuffer.Destroy, nil
} }
// setupDefaultVault creates the default vault and derives its long-term // setupDefaultVault creates the default vault and derives its long-term
// identity from the mnemonic // identity from the mnemonic
func (cli *Instance) setupDefaultVault( func (cli *Instance) setupDefaultVault(
stateDir, mnemonicStr string, stateDir string, mnemonic *memguard.LockedBuffer,
) (*vault.Vault, *age.X25519Identity, error) { ) (*vault.Vault, *age.X25519Identity, error) {
// Create the default vault - it will handle key derivation internally // Create the default vault - it will handle key derivation internally
secret.Debug("Creating default vault") secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default") vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
if err != nil { if err != nil {
secret.Debug("Failed to create default vault", "error", err) secret.Debug("Failed to create default vault", "error", err)
@@ -92,7 +96,7 @@ func (cli *Instance) setupDefaultVault(
} }
// Derive the long-term key using the same index that CreateVault used // Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil { if err != nil {
secret.Debug("Failed to derive long-term key", "error", err) secret.Debug("Failed to derive long-term key", "error", err)
@@ -136,12 +140,13 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
} }
// Prompt for mnemonic // Prompt for mnemonic
mnemonicStr, cleanupMnemonic, err := promptMnemonic() mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil { if err != nil {
return err return err
} }
defer cleanupMnemonic() defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" { if mnemonicStr == "" {
secret.Debug("Empty mnemonic provided") secret.Debug("Empty mnemonic provided")
@@ -162,18 +167,14 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
// Ask for the unlocker passphrase before creating the vault, so that // Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind // stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase() passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil { if err != nil {
return err return err
} }
defer passphraseBuffer.Destroy() defer cleanupPassphrase()
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
// Create the default vault and derive its long-term key // Create the default vault and derive its long-term key
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr) vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
if err != nil { if err != nil {
return err return err
} }
+14 -7
View File
@@ -286,7 +286,7 @@ func TestSecretManagerIntegration(t *testing.T) {
// Test 25: Concurrent operations // Test 25: Concurrent operations
// Purpose: Test multiple simultaneous operations // Purpose: Test multiple simultaneous operations
// Expected: Proper locking/synchronization, no corruption // Expected: Proper locking/synchronization, no corruption
test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv) test25ConcurrentOperations(t, tempDir, secretPath, testMnemonic, runSecret)
// Test 26: Large secret values // Test 26: Large secret values
// Purpose: Test with large secret values (e.g., certificates) // Purpose: Test with large secret values (e.g., certificates)
@@ -2009,28 +2009,35 @@ func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic,
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2))) assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
} }
func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test25ConcurrentOperations(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) {
t.Helper() t.Helper()
// Make sure we're in default vault // Make sure we're in default vault
_, err := runSecret("vault", "select", "default") _, err := runSecret("vault", "select", "default")
require.NoError(t, err, "vault select should succeed") require.NoError(t, err, "vault select should succeed")
// Run multiple concurrent reads // Run multiple concurrent reads, as separate processes: within one
// process the first command to read the mnemonic would unset it for
// the others
const numReaders = 5 const numReaders = 5
errCh := make(chan error, numReaders) errCh := make(chan error, numReaders)
for i := range numReaders { for i := range numReaders {
go func(id int) { go func(id int) {
output, err := runSecretWithEnv(map[string]string{ cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password")
secret.EnvMnemonic: testMnemonic, cmd.Env = []string{
}, "get", "database/password") secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.Output()
switch { switch {
case err != nil: case err != nil:
errCh <- fmt.Errorf("reader %d failed: %w", id, err) errCh <- fmt.Errorf("reader %d failed: %w", id, err)
case strings.TrimSpace(output) == "": case strings.TrimSpace(string(output)) == "":
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id) errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
default: default:
errCh <- nil errCh <- nil
+32 -20
View File
@@ -52,15 +52,18 @@ func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
} }
// addAtOnce runs one add of the secret name per value, all at once, and // addAtOnce runs one add of the secret name per value, all at once, and
// returns their errors. // returns their errors. Each add is given mnemonic, which a forced add
// needs.
func addAtOnce( func addAtOnce(
fs afero.Fs, stateDir, name string, force bool, values []string, fs afero.Fs, stateDir, name string, force bool, values []string,
mnemonic *memguard.LockedBuffer,
) []error { ) []error {
errs := make(chan error, len(values)) errs := make(chan error, len(values))
for _, value := range values { for _, value := range values {
go func() { go func() {
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = mnemonic
cli.cmd = &cobra.Command{} cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader(value)) cli.cmd.SetIn(strings.NewReader(value))
@@ -92,9 +95,9 @@ func numbered(prefix string, count int) []string {
// forced adds read the same highest version number and overwrite each // forced adds read the same highest version number and overwrite each
// other's version. With it they behave as if run one after another. // other's version. With it they behave as if run one after another.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // times commands against the in-memory lock all tests share
func TestConcurrentAddsKeepEveryVersion(t *testing.T) { func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
const adds = 8 const adds = 8
@@ -107,14 +110,14 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()}, {"real", afero.NewOsFs(), t.TempDir()},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default") _, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
require.NoError(t, err) require.NoError(t, err)
// One add creates the secret; the others find that it exists // One add creates the secret; the others find that it exists
created := 0 created := 0
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false, for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
numbered("create", adds)) { numbered("create", adds), mnemonic) {
if err == nil { if err == nil {
created++ created++
} else { } else {
@@ -126,13 +129,15 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
// Every forced add stores a version of its own // Every forced add stores a version of its own
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true, for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
numbered("force", adds)) { numbered("force", adds), mnemonic) {
require.NoError(t, err) require.NoError(t, err)
} }
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir) vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = mnemonic
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
@@ -176,11 +181,11 @@ func (r *readNotifier) Read(p []byte) (int, error) {
// taken the state directory lock before reading, it would hold the lock // taken the state directory lock before reading, it would hold the lock
// while waiting for encrypt's output, and encrypt would wait for the lock // while waiting for encrypt's output, and encrypt would wait for the lock
// to store its key: neither would finish. // to store its key: neither would finish.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptPipedIntoAdd(t *testing.T) { func TestEncryptPipedIntoAdd(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default") _, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600)) require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
@@ -283,14 +288,16 @@ func setupEveryCommand(
) (string, string) { ) (string, string) {
t.Helper() t.Helper()
other, err := vault.CreateVault(fs, testStateDir, "other") mnemonic := testMnemonicBuffer(t)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
require.NoError(t, err) require.NoError(t, err)
otherDir, err := other.GetDirectory() otherDir, err := other.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age"))) require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work") vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
require.NoError(t, err) require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false) addTestSecret(t, vlt, []byte("older"), false)
@@ -363,7 +370,12 @@ func requireWaitsForLock(
release = sync.OnceFunc(release) release = sync.OnceFunc(release)
defer release() defer release()
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer unlockPassphrase.Destroy()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
cli.UnlockPassphrase = unlockPassphrase
cli.cmd = &cobra.Command{} cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader("value")) cli.cmd.SetIn(strings.NewReader("value"))
cli.cmd.SetOut(io.Discard) cli.cmd.SetOut(io.Discard)
@@ -400,11 +412,8 @@ func requireWaitsForLock(
// TestChangingCommandsWaitForLock checks that each command that changes the // TestChangingCommandsWaitForLock checks that each command that changes the
// state directory waits for its lock. // state directory waits for its lock.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
func TestChangingCommandsWaitForLock(t *testing.T) { func TestChangingCommandsWaitForLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
withUnlocker bool withUnlocker bool
@@ -468,15 +477,18 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a // TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
// key that already exists, which only reads the state directory, finishes // key that already exists, which only reads the state directory, finishes
// while another command holds the state directory lock. // while another command holds the state directory lock.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) { func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default") _, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600)) require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir) encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
encrypt.Mnemonic = mnemonic
encrypt.cmd = &cobra.Command{} encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(io.Discard) encrypt.cmd.SetOut(io.Discard)
@@ -505,11 +517,11 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
// state directory lock by the time it writes its output. Holding it while // state directory lock by the time it writes its output. Holding it while
// streaming would stall every other changing command for as long as the // streaming would stall every other changing command for as long as the
// stream lasts, and forever when the other end of the pipe is one of them. // stream lasts, and forever when the other end of the pipe is one of them.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptStreamsUnlocked(t *testing.T) { func TestEncryptStreamsUnlocked(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default") _, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600)) require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
+13 -12
View File
@@ -6,7 +6,6 @@ import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -20,9 +19,9 @@ import (
// move within "work" left "work" the current vault. "default" is the current // move within "work" left "work" the current vault. "default" is the current
// vault in every case, and each case runs on its own copy of the state // vault in every case, and each case runs on its own copy of the state
// directory. // directory.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) { func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
require.Equal(t, "default", before[testStateDir+"/currentvault"]) require.Equal(t, "default", before[testStateDir+"/currentvault"])
@@ -72,6 +71,8 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newFsFromSnapshot(t, before) fs := newFsFromSnapshot(t, before)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
@@ -86,9 +87,9 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x // TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and // work:y`, with "default" the current vault, renames "x" to "y" in "work" and
// leaves "default" the current vault. // leaves "default" the current vault.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) { func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t) fs := newTwoVaultFs(t)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
@@ -111,10 +112,8 @@ func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
// the secret "x", and the secrets.d of "other" is a link to that of // the secret "x", and the secrets.d of "other" is a link to that of
// "default", so other:x is default:x. Each move must be rejected and leave // "default", so other:x is default:x. Each move must be rejected and leave
// the secret and the links as they were. // the secret and the links as they were.
//
//nolint:paralleltest // t.Setenv
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) { func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
const isSame = "is the same secret on this filesystem" const isSame = "is the same secret on this filesystem"
@@ -150,15 +149,17 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := t.TempDir() stateDir := t.TempDir()
vaultsDir := filepath.Join(stateDir, "vaults.d") vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault. // "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other") _, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default") vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false) err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
@@ -199,12 +200,12 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo" // and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
// with "Foo". // with "Foo".
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) { func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := t.TempDir() stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default") vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false) err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
+34 -15
View File
@@ -33,6 +33,17 @@ const (
missingFile = "/no/such/file" missingFile = "/no/such/file"
) )
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir. // The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
// Creating a passphrase unlocker is slow by design, so the vaults are made // Creating a passphrase unlocker is slow by design, so the vaults are made
// once, by the first test that needs them. // once, by the first test that needs them.
@@ -52,13 +63,12 @@ var (
func newTwoVaultFs(t *testing.T) afero.Fs { func newTwoVaultFs(t *testing.T) afero.Fs {
t.Helper() t.Helper()
t.Setenv(secret.EnvMnemonic, testMnemonic)
twoVaultsOnce.Do(func() { twoVaultsOnce.Do(func() {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
for _, name := range []string{"work", "default"} { for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name) vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
require.NoError(t, err) require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false) err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
@@ -163,7 +173,7 @@ func requireRejectedAndUnchanged(
// Moves and imports use --force, so that only the name check stands in // Moves and imports use --force, so that only the name check stands in
// the way. // the way.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // the cases share cmd
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) { func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// Creating a passphrase unlocker is slow by design, so the vaults are // Creating a passphrase unlocker is slow by design, so the vaults are
// created once and each case runs on its own copy of them. // created once and each case runs on its own copy of them.
@@ -259,7 +269,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// `secret version rm x ""` every version of x. A version argument is // `secret version rm x ""` every version of x. A version argument is
// accepted only if it is one of the versions `secret version list` lists. // accepted only if it is one of the versions `secret version list` lists.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // the cases share cmd
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) { func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
@@ -297,15 +307,17 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
// `secret vault import ..` wrote a long-term key and an unlocker into the // `secret vault import ..` wrote a long-term key and an unlocker into the
// state directory itself, and `secret vault select ..` made it the current // state directory itself, and `secret vault select ..` made it the current
// vault. Each command that takes a vault name must reject an invalid one // vault. Each command that takes a vault name must reject an invalid one
// before building a path from it. The mnemonic and the passphrase are set, // before building a path from it. The instance is given the mnemonic and
// and moves and removals use --force, so that only the name check stands // the passphrase, and moves and removals use --force, so that only the name
// in the way. // check stands in the way.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // the cases share cmd
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) { func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -338,7 +350,12 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
for _, name := range []string{"", ".", "..", "a/b"} { for _, name := range []string{"", ".", "..", "a/b"} {
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) { t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name), requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
func(c *cli.Instance) error { return tt.run(c, name) }) func(c *cli.Instance) error {
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return tt.run(c, name)
})
}) })
} }
} }
@@ -347,14 +364,16 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm` // TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
// with a version that is not the current one removes that version and // with a version that is not the current one removes that version and
// changes nothing else. // changes nothing else.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) { func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t) fs := newTwoVaultFs(t)
vlt, err := vault.GetCurrentVault(fs, testStateDir) vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
// A second version of "x" becomes the current one. // A second version of "x" becomes the current one.
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true) err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
require.NoError(t, err) require.NoError(t, err)
@@ -388,9 +407,9 @@ func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`, // TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
// where "work" is also the name of a vault, renames the secret "x" to "work" // where "work" is also the name of a vault, renames the secret "x" to "work"
// in the current vault and changes nothing else. // in the current vault and changes nothing else.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) { func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
fs := newFsFromSnapshot(t, before) fs := newFsFromSnapshot(t, before)
+24
View File
@@ -81,6 +81,9 @@ func newAddCmd() *cobra.Command {
cli.cmd = cmd // Set the command for stdin access cli.cmd = cmd // Set the command for stdin access
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
secret.Debug("Created CLI instance, calling AddSecret") secret.Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force) return cli.AddSecret(args[0], force)
@@ -111,6 +114,9 @@ func newGetCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
// Without --version, get the current version. A given // Without --version, get the current version. A given
// --version is checked as typed, so an empty one is rejected. // --version is checked as typed, so an empty one is rejected.
if !cmd.Flags().Changed("version") { if !cmd.Flags().Changed("version") {
@@ -174,6 +180,9 @@ func newImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ImportSecret(cmd, args[0], sourceFile, force) return cli.ImportSecret(cmd, args[0], sourceFile, force)
}, },
} }
@@ -248,6 +257,9 @@ The source secret is deleted after successful copy.`,
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.MoveSecret(cmd, args[0], args[1], force) return cli.MoveSecret(cmd, args[0], args[1], force)
}, },
} }
@@ -354,6 +366,8 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
secret.Debug("Got current vault", "vault_name", vlt.GetName()) secret.Debug("Got current vault", "vault_name", vlt.GetName())
// Read secret value directly into protected buffers // Read secret value directly into protected buffers
@@ -420,6 +434,8 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
value, err := vlt.GetSecret(secretName) value, err := vlt.GetSecret(secretName)
if err != nil { if err != nil {
return err return err
@@ -448,6 +464,8 @@ func (cli *Instance) GetSecretWithVersion(
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get the secret value // Get the secret value
value, err := vlt.GetSecretVersion(secretName, version) value, err := vlt.GetSecretVersion(secretName, version)
if err != nil { if err != nil {
@@ -633,6 +651,8 @@ func (cli *Instance) ImportSecret(
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Read secret value from the source file into protected buffers // Read secret value from the source file into protected buffers
file, err := cli.fs.Open(sourceFile) file, err := cli.fs.Open(sourceFile)
if err != nil { if err != nil {
@@ -993,6 +1013,10 @@ func (cli *Instance) moveSecretCrossVault(
destVault.Name, destSecretName) destVault.Name, destSecretName)
} }
// Copying needs the long-term keys of both vaults
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Unlock destination vault (will fail if neither mnemonic nor unlocker available) // Unlock destination vault (will fail if neither mnemonic nor unlocker available)
_, err = destVault.GetOrDeriveLongTermKey() _, err = destVault.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
+6 -10
View File
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -71,11 +70,8 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set test mnemonic
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault // Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName) _, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
@@ -205,7 +201,7 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin // TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
// //
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault //nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretVariousSizes(t *testing.T) { func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -265,7 +261,7 @@ func TestAddSecretVariousSizes(t *testing.T) {
// TestImportSecretVariousSizes tests importing secrets of various sizes from files // TestImportSecretVariousSizes tests importing secrets of various sizes from files
// //
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault //nolint:paralleltest // together the subtests lock more than the memlock limit
func TestImportSecretVariousSizes(t *testing.T) { func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -325,7 +321,7 @@ func TestImportSecretVariousSizes(t *testing.T) {
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly // TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
// //
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault //nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretBufferGrowth(t *testing.T) { func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth // Test various sizes that should trigger buffer growth
sizes := []int{ sizes := []int{
@@ -392,9 +388,9 @@ func TestAddSecretBufferGrowth(t *testing.T) {
} }
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly // TestAddSecretStreamingBehavior tests that we handle streaming input correctly
//
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
func TestAddSecretStreamingBehavior(t *testing.T) { func TestAddSecretStreamingBehavior(t *testing.T) {
t.Parallel()
fs, vlt := newSizeTestVault(t) fs, vlt := newSizeTestVault(t)
// Create a custom reader that simulates slow streaming input // Create a custom reader that simulates slow streaming input
+15 -11
View File
@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -230,6 +229,9 @@ func newUnlockerAddCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
unlockerType := args[0] unlockerType := args[0]
// Validate unlocker type // Validate unlocker type
@@ -580,19 +582,19 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// For passphrase unlockers, we don't need the vault to be unlocked // For passphrase unlockers, we don't need the vault to be unlocked
// The CreatePassphraseUnlocker method will handle getting the // The CreatePassphraseUnlocker method will handle getting the
// long-term key // long-term key
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if passphrase is set in environment variable // The new unlocker gets the passphrase from the environment, which also
var passphraseBuffer *memguard.LockedBuffer // unlocks the current passphrase unlocker, else the one entered here
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { passphraseBuffer := cli.UnlockPassphrase
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase)) if passphraseBuffer == nil {
} else {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
defer passphraseBuffer.Destroy()
} }
defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil { if err != nil {
@@ -613,7 +615,8 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
return errKeychainMacOSOnly return errKeychainMacOSOnly
} }
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir) keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err) return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
} }
@@ -643,7 +646,8 @@ func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
return errSecureEnclaveMacOSOnly return errSecureEnclaveMacOSOnly
} }
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir) seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err) return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
} }
@@ -707,8 +711,8 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker) return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
} }
pgpUnlocker, err := secret.CreatePGPUnlocker( pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
cli.fs, cli.stateDir, gpgKeyID, fingerprint) gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
return err return err
} }
+72 -2
View File
@@ -5,18 +5,88 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has. // unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
const unknownTestGPGUserID = "not-in-keyring@example.com" const unknownTestGPGUserID = "not-in-keyring@example.com"
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
const (
addTestSecretName = "api-key"
addTestSecretValue = "value"
)
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
// with a passphrase unlocker, getting the vault's long-term key from the
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
// then reads a secret with neither the mnemonic nor the passphrase given, so
// through the new unlocker, which the add selects.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlocker(t *testing.T) {
newTestGPGKey(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
tests := []struct {
name string
// mnemonic is the mnemonic given while the unlocker is added, or nil.
mnemonic *memguard.LockedBuffer
}{
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
{"long-term key from the current unlocker", nil},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
err = vlt.AddSecret(addTestSecretName,
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
require.NoError(t, err)
_, err = vlt.CreatePassphraseUnlocker(
memguard.NewBufferFromBytes([]byte(testPassphrase)))
require.NoError(t, err)
instance, cmd := newTestInstance(fs)
instance.Mnemonic = test.mnemonic
instance.UnlockPassphrase = passphrase
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
current, err := reopened.GetCurrentUnlocker()
require.NoError(t, err)
assert.Equal(t, unlockerTypePGP, current.GetType())
value, err := reopened.GetSecret(addTestSecretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, addTestSecretValue, value.String())
})
}
}
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key // TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
// the keyring does not hold fails at looking up the key's fingerprint and // the keyring does not hold fails at looking up the key's fingerprint and
// leaves no new unlocker directory. The error must come from the lookup: a // leaves no new unlocker directory. The error must come from the lookup: a
// lookup moved after anything is written would also come after getting the // lookup moved after anything is written would also come after getting the
// vault's long-term key, which fails first on every platform but macOS // vault's long-term key, which fails first here: this vault's unlockers hold
// (https://git.eeqj.de/sneak/secret/issues/88). // no keys.
// //
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests //nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerUnknownKey(t *testing.T) { func TestAddPGPUnlockerUnknownKey(t *testing.T) {
+10 -1
View File
@@ -122,7 +122,8 @@ func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
} }
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key // newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
// without a passphrase there, and returns the key's fingerprint. // without a passphrase there, with a subkey for encryption, and returns the
// key's fingerprint.
func newTestGPGKey(t *testing.T) string { func newTestGPGKey(t *testing.T) string {
t.Helper() t.Helper()
@@ -151,6 +152,14 @@ func newTestGPGKey(t *testing.T) string {
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID) fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
require.NoError(t, err) require.NoError(t, err)
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
"--pinentry-mode", "loopback", "--passphrase", "",
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
).CombinedOutput()
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
output)
return fingerprint return fingerprint
} }
+27 -60
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -85,6 +84,9 @@ func newVaultCreateCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.CreateVault(cmd, args[0]) return cli.CreateVault(cmd, args[0])
}, },
} }
@@ -136,6 +138,9 @@ func newVaultImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.VaultImport(cmd, vaultName) return cli.VaultImport(cmd, vaultName)
}, },
} }
@@ -228,28 +233,14 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return nil return nil
} }
// setMnemonicEnv sets the mnemonic environment variable and returns a // resolvePassphrase returns the unlock passphrase from the environment,
// function that restores the previous value // cli.UnlockPassphrase, or prompts the user for it with confirmation. The
func setMnemonicEnv(mnemonicStr string) func() { // returned cleanup function must be deferred by the caller.
originalMnemonic := os.Getenv(secret.EnvMnemonic) func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr) if cli.UnlockPassphrase != nil {
return func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}
}
// resolvePassphrase returns the unlock passphrase from the environment or
// prompts the user for it with confirmation
func resolvePassphrase() (*memguard.LockedBuffer, error) {
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil return cli.UnlockPassphrase, func() {}, nil
} }
secret.Debug("Prompting user for unlock passphrase") secret.Debug("Prompting user for unlock passphrase")
@@ -257,10 +248,10 @@ func resolvePassphrase() (*memguard.LockedBuffer, error) {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read passphrase: %w", err) return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
} }
return passphraseBuffer, nil return passphraseBuffer, passphraseBuffer.Destroy, nil
} }
// CreateVault creates a new vault // CreateVault creates a new vault
@@ -273,30 +264,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
} }
defer release() defer release()
// Get or prompt for mnemonic mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
var mnemonicStr string if err != nil {
return err
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
secret.Debug("Using mnemonic from environment variable")
mnemonicStr = envMnemonic
} else {
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase(
"Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return fmt.Errorf("failed to read mnemonic: %w", err)
}
defer mnemonicBuffer.Destroy()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
} }
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" { if mnemonicStr == "" {
return errMnemonicEmpty return errMnemonicEmpty
} }
@@ -311,18 +285,14 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
// Ask for the unlocker passphrase before creating the vault, so that // Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind // stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase() passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil { if err != nil {
return err return err
} }
defer passphraseBuffer.Destroy() defer cleanupPassphrase()
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
// Create the vault - it will handle key derivation internally // Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name) vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
if err != nil { if err != nil {
return err return err
} }
@@ -412,11 +382,12 @@ func (cli *Instance) vaultImportPreflight(
} }
// Get mnemonic from environment // Get mnemonic from environment
mnemonic := os.Getenv(secret.EnvMnemonic) if cli.Mnemonic == nil {
if mnemonic == "" {
return "", "", "", errMnemonicEnvNotSet return "", "", "", errMnemonicEnvNotSet
} }
mnemonic := cli.Mnemonic.String()
// Validate the mnemonic // Validate the mnemonic
mnemonicWords := strings.Fields(mnemonic) mnemonicWords := strings.Fields(mnemonic)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
@@ -539,17 +510,13 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
} }
// Get passphrase from environment variable // Get passphrase from environment variable
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase) passphraseBuffer := cli.UnlockPassphrase
if passphraseStr == "" { if passphraseBuffer == nil {
return errPassphraseEnvNotSet return errPassphraseEnvNotSet
} }
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
// Create secure buffer for passphrase
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
defer passphraseBuffer.Destroy()
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
+5
View File
@@ -54,6 +54,9 @@ func VersionCommands(cli *Instance) *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 {
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ListVersions(cmd, args[0]) return cli.ListVersions(cmd, args[0])
}, },
} }
@@ -172,6 +175,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
currentVersion = "" currentVersion = ""
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get long-term key for decrypting metadata // Get long-term key for decrypting metadata
ltIdentity, err := vlt.GetOrDeriveLongTermKey() ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
+35 -11
View File
@@ -45,6 +45,17 @@ const (
testStateDir = "/test/state" testStateDir = "/test/state"
) )
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// Helper function to add a version of the "test/secret" secret to the // Helper function to add a version of the "test/secret" secret to the
// vault with proper buffer protection // vault with proper buffer protection
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) { func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
@@ -61,11 +72,8 @@ func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
func setupTestVault(t *testing.T, fs afero.Fs) { func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper() t.Helper()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault // Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default") vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -83,11 +91,13 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
require.NoError(t, err) require.NoError(t, err)
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsCommand(t *testing.T) { func TestListVersionsCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key // Set up vault with long-term key
setupTestVault(t, fs) setupTestVault(t, fs)
@@ -96,6 +106,8 @@ func TestListVersionsCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false) addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -139,8 +151,9 @@ func TestListVersionsCommand(t *testing.T) {
assert.Equal(t, 2, versionLines) assert.Equal(t, 2, versionLines)
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsNonExistentSecret(t *testing.T) { func TestListVersionsNonExistentSecret(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -161,8 +174,9 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteVersionCommand(t *testing.T) { func TestPromoteVersionCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -174,6 +188,8 @@ func TestPromoteVersionCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
addTestSecret(t, vlt, []byte("version-1"), false) addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -224,8 +240,9 @@ func TestPromoteVersionCommand(t *testing.T) {
assert.Equal(t, []byte("version-1"), promoted.Bytes()) assert.Equal(t, []byte("version-1"), promoted.Bytes())
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteNonExistentVersion(t *testing.T) { func TestPromoteNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -252,11 +269,13 @@ func TestPromoteNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWithVersion(t *testing.T) { func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key // Set up vault with long-term key
setupTestVault(t, fs) setupTestVault(t, fs)
@@ -265,6 +284,8 @@ func TestGetSecretWithVersion(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false) addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -298,10 +319,12 @@ func TestGetSecretWithVersion(t *testing.T) {
assert.Equal(t, "version-1", buf.String()) assert.Equal(t, "version-1", buf.String())
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWritesBinaryValue(t *testing.T) { func TestGetSecretWritesBinaryValue(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
setupTestVault(t, fs) setupTestVault(t, fs)
@@ -361,8 +384,9 @@ func TestVersionCommandStructure(t *testing.T) {
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short) assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsEmptyOutput(t *testing.T) { func TestListVersionsEmptyOutput(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
+4 -8
View File
@@ -38,8 +38,7 @@ const (
) )
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth. // 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 // Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion).
// (for deletion).
func CreateKey(label string) (publicKey []byte, hash string, err error) { func CreateKey(label string) (publicKey []byte, hash string, err error) {
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize) pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize) pubKeyLen := C.int(p256UncompressedKeySize)
@@ -58,8 +57,7 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
//nolint:nlreturn // CGo result extraction pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
h := C.GoString(&hashBuf[0]) h := C.GoString(&hashBuf[0])
return pk, h, nil return pk, h, nil
@@ -85,8 +83,7 @@ func Encrypt(label string, plaintext []byte) ([]byte, error) {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
//nolint:nlreturn // CGo result extraction out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
return out, nil return out, nil
} }
@@ -110,8 +107,7 @@ func Decrypt(label string, ciphertext []byte) ([]byte, error) {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
//nolint:nlreturn // CGo result extraction out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
return out, nil return out, nil
} }
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !darwin || !cgo //go:build !darwin
// Package macse provides Go bindings for macOS Secure Enclave operations. // Package macse provides Go bindings for macOS Secure Enclave operations.
package macse package macse
+4 -5
View File
@@ -1,4 +1,5 @@
//go:build darwin && cgo //go:build darwin
// +build darwin
package macse package macse
@@ -44,8 +45,7 @@ func TestCreateAndDeleteKey(t *testing.T) {
// Verify valid uncompressed P-256 public key // Verify valid uncompressed P-256 public key
if len(pubKey) != p256UncompressedKeySize { if len(pubKey) != p256UncompressedKeySize {
t.Fatalf("expected public key length %d, got %d", t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey))
p256UncompressedKeySize, len(pubKey))
} }
if pubKey[0] != 0x04 { if pubKey[0] != 0x04 {
@@ -83,8 +83,7 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
}() }()
// Test data simulating an age private key // Test data simulating an age private key
plaintext := []byte("AGE-SECRET-KEY-1" + plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
// Encrypt // Encrypt
ciphertext, err := Encrypt(testKeyLabel, plaintext) ciphertext, err := Encrypt(testKeyLabel, plaintext)
+25 -21
View File
@@ -219,7 +219,7 @@ func newVaultWithSecret(
) *vault.Vault { ) *vault.Vault {
t.Helper() t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name) vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value)) buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -329,14 +329,14 @@ func TestRemoveDirAtomic(t *testing.T) {
// named with 255 bytes, the most a file name may have, on the real // named with 255 bytes, the most a file name may have, on the real
// filesystem: the temporary directories they use must fit that limit too. // filesystem: the temporary directories they use must fit that limit too.
func TestLongestNames(t *testing.T) { func TestLongestNames(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
const longestName = 255 const longestName = 255
fs := afero.NewOsFs() fs := afero.NewOsFs()
name := strings.Repeat("a", longestName) name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name) vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long")) value := memguard.NewBufferFromBytes([]byte("long"))
@@ -361,13 +361,13 @@ func TestLongestNames(t *testing.T) {
// another vault, as a forced move between vaults does, and makes the last // another vault, as a forced move between vaults does, and makes the last
// step that completes the copy fail. The secret it was to replace must // step that completes the copy fail. The secret it was to replace must
// still be there unchanged: it may go only once its replacement is whole. // still be there unchanged: it may go only once its replacement is whole.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) { func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
src := newVaultWithSecret(t, base, stateDir, "source", "new") src := newVaultWithSecret(t, base, stateDir, "source", "new")
dest := newVaultWithSecret(t, base, stateDir, "dest", "old") dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
@@ -400,13 +400,13 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
// directory directly in secrets.d or in a versions directory. Those are // directory directly in secrets.d or in a versions directory. Those are
// listed to find secrets and versions, so a temporary directory made there // listed to find secrets and versions, so a temporary directory made there
// would be listed while half-built, and one left by a crash would stay. // would be listed while half-built, and one left by a crash would stay.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestTempDirsStayOutOfListings(t *testing.T) { func TestTempDirsStayOutOfListings(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
newVaultWithSecret(t, base, stateDir, "default", "first") newVaultWithSecret(t, base, stateDir, "default", "first")
@@ -419,6 +419,7 @@ func TestTempDirsStayOutOfListings(t *testing.T) {
return nil return nil
}} }}
vlt := vault.NewVault(fs, stateDir, "default") vlt := vault.NewVault(fs, stateDir, "default")
vlt.Mnemonic = testMnemonicBuffer(t)
value := memguard.NewBufferFromBytes([]byte("second")) value := memguard.NewBufferFromBytes([]byte("second"))
defer value.Destroy() defer value.Destroy()
@@ -527,13 +528,13 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
// unlocker again and checks, before each change this makes, that the file // unlocker again and checks, before each change this makes, that the file
// naming the current one exists: a reader or a crash never finds it // naming the current one exists: a reader or a crash never finds it
// missing. // missing.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestCurrentFilesNeverMissing(t *testing.T) { func TestCurrentFilesNeverMissing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value") vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
@@ -625,11 +626,11 @@ func TestWriteFileAtomicTempFile(t *testing.T) {
// anything, so that it never leaves a partial unlocker, nor breaks the one // anything, so that it never leaves a partial unlocker, nor breaks the one
// it would replace. // it would replace.
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) { func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from t.Parallel()
t.Setenv(secret.EnvMnemonic, "")
// No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName) _, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
require.NoError(t, err) require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error { fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -650,17 +651,18 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// creating a passphrase unlocker makes, that the unlocker's directory either // creating a passphrase unlocker makes, that the unlocker's directory either
// does not exist or holds all of its files: a crash or a failure at any point // does not exist or holds all of its files: a crash or a failure at any point
// leaves no partial unlocker. // leaves no partial unlocker.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) { func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile} files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName) vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
@@ -683,8 +685,10 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase)) passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy() defer passphrase.Destroy()
_, err = vault.NewVault(fs, stateDir, testVaultName). hooked := vault.NewVault(fs, stateDir, testVaultName)
CreatePassphraseUnlocker(passphrase) hooked.Mnemonic = vlt.Mnemonic
_, err = hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir)) assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir))
}) })
+16 -21
View File
@@ -1,4 +1,4 @@
//go:build darwin && cgo //go:build darwin
package secret package secret
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"time" "time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -30,25 +31,18 @@ func (v *realVault) GetName() string { return v.name }
func (v *realVault) GetFilesystem() afero.Fs { return v.fs } func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface. // Unused by getLongTermPrivateKey — these satisfy VaultInterface.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
panic("not used") func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
} func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) { panic("not used") }
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) { panic("not used") }
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) { panic("not used") }
panic("not used") func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
}
func (v *realVault) CreatePassphraseUnlocker(
*memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
panic("not used") panic("not used")
} }
// createRealVault sets up a complete vault directory structure on an in-memory // createRealVault sets up a complete vault directory structure on an in-memory
// filesystem, identical to what vault.CreateVault produces. // filesystem, identical to what vault.CreateVault produces.
func createRealVault( func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
) *realVault {
t.Helper() t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", name) vaultDir := filepath.Join(stateDir, "vaults.d", name)
@@ -61,15 +55,15 @@ func createRealVault(
} }
metaBytes, err := json.Marshal(metadata) metaBytes, err := json.Marshal(metadata)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
return &realVault{name: name, stateDir: stateDir, fs: fs} return &realVault{name: name, stateDir: stateDir, fs: fs}
} }
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) { func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
const testMnemonic = "abandon abandon abandon abandon abandon abandon " + t.Parallel()
"abandon abandon abandon abandon abandon about"
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. // Derive expected keys at two different indices to prove they differ.
key0, err := agehd.DeriveIdentity(testMnemonic, 0) key0, err := agehd.DeriveIdentity(testMnemonic, 0)
@@ -83,9 +77,10 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5) vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic) mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault) result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
defer result.Destroy() defer result.Destroy()
+55 -65
View File
@@ -1,4 +1,5 @@
//go:build darwin && cgo //go:build darwin
// +build darwin
package secret package secret
@@ -10,6 +11,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"time" "time"
"filippo.io/age" "filippo.io/age"
@@ -22,9 +24,7 @@ import (
const ( const (
agePrivKeyPassphraseLength = 64 agePrivKeyPassphraseLength = 64
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items // KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
// KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
//nolint:revive // ALL_CAPS is intentional for this constant
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
) )
// keychainItemNameRegex validates keychain item names // keychainItemNameRegex validates keychain item names
@@ -64,8 +64,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName) Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
keychainDataBytes, err := retrieveFromKeychain(keychainItemName) keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
if err != nil { if err != nil {
Debug("Failed to retrieve data from keychain", Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
"error", err, "keychain_item", keychainItemName)
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err) return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
} }
@@ -96,8 +95,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath) encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
if err != nil { if err != nil {
Debug("Failed to read encrypted age private key", Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
"error", err, "path", agePrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err) return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
} }
@@ -108,16 +106,12 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
) )
// Step 5: Decrypt the age private key using the passphrase from keychain // Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
"unlocker_id", k.GetID()) agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
agePrivKeyBuffer, err := DecryptWithPassphrase(
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil { if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
"error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
"failed to decrypt age private key with keychain passphrase: %w", err)
} }
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
@@ -180,8 +174,7 @@ func (k *KeychainUnlocker) Remove() error {
// Step 1: Get keychain item name // Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName() keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name during removal", Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
"error", err, "unlocker_id", k.GetID())
return fmt.Errorf("failed to get keychain item name: %w", err) return fmt.Errorf("failed to get keychain item name: %w", err)
} }
@@ -189,8 +182,7 @@ func (k *KeychainUnlocker) Remove() error {
// Step 2: Remove from keychain // Step 2: Remove from keychain
Debug("Removing keychain item", "keychain_item", keychainItemName) Debug("Removing keychain item", "keychain_item", keychainItemName)
if err := deleteFromKeychain(keychainItemName); err != nil { if err := deleteFromKeychain(keychainItemName); err != nil {
Debug("Failed to remove keychain item", Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
"error", err, "keychain_item", keychainItemName)
return fmt.Errorf("failed to remove keychain item: %w", err) return fmt.Errorf("failed to remove keychain item: %w", err)
} }
@@ -198,22 +190,18 @@ func (k *KeychainUnlocker) Remove() error {
// Step 3: Remove directory // Step 3: Remove directory
Debug("Removing keychain unlocker directory", "directory", k.Directory) Debug("Removing keychain unlocker directory", "directory", k.Directory)
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil { if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
Debug("Failed to remove keychain unlocker directory", Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
"error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err) return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
} }
Debug("Successfully removed keychain unlocker", Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
return nil return nil
} }
// NewKeychainUnlocker creates a new KeychainUnlocker instance // NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker( func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *KeychainUnlocker {
return &KeychainUnlocker{ return &KeychainUnlocker{
Directory: directory, Directory: directory,
Metadata: metadata, Metadata: metadata,
@@ -251,15 +239,14 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
} }
// getLongTermPrivateKey retrieves the long-term private key either from // getLongTermPrivateKey derives the long-term private key from mnemonic when
// environment or current unlocker // it is not nil, else gets it through the current unlocker, which is given
// passphrase when it is a passphrase unlocker.
// Returns a LockedBuffer to ensure the private key is protected in memory // Returns a LockedBuffer to ensure the private key is protected in memory
func getLongTermPrivateKey( func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
// Check if mnemonic is available in environment variable if mnemonic != nil {
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// Read vault metadata to get the correct derivation index // Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory() vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
@@ -278,10 +265,9 @@ func getLongTermPrivateKey(
} }
// Use mnemonic with the vault's actual derivation index // Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
"failed to derive long-term key from mnemonic: %w", err)
} }
// Return the private key in a secure buffer // Return the private key in a secure buffer
@@ -294,6 +280,10 @@ func getLongTermPrivateKey(
return nil, fmt.Errorf("failed to get current unlocker: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
// Get the current unlocker identity // Get the current unlocker identity
currentUnlockerIdentity, err := currentUnlocker.GetIdentity() currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
@@ -305,40 +295,31 @@ func getLongTermPrivateKey(
switch currentUnlocker := currentUnlocker.(type) { switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker: case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker // Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key "+ return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
"from current passphrase unlocker: %w", err)
} }
case *PGPUnlocker: case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker // Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key "+ return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
"from current PGP unlocker: %w", err)
} }
case *KeychainUnlocker: case *KeychainUnlocker:
// Read the encrypted long-term private key from another keychain // Read the encrypted long-term private key from another keychain unlocker
// unlocker encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key "+ return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
"from current keychain unlocker: %w", err)
} }
default: default:
return nil, fmt.Errorf( return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
"unsupported current unlocker type for keychain unlocker creation")
} }
// Decrypt long-term private key using current unlocker // Decrypt long-term private key using current unlocker
ltPrivKeyBuffer, err := DecryptWithIdentity( ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
@@ -347,8 +328,12 @@ func getLongTermPrivateKey(
return ltPrivKeyBuffer, nil return ltPrivKeyBuffer, nil
} }
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault // CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) { // vault. The long-term key comes from mnemonic when it is not nil, else from
// the current unlocker, as getLongTermPrivateKey describes.
func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Check if we're on macOS // Check if we're on macOS
if err := checkMacOSAvailable(); err != nil { if err := checkMacOSAvailable(); err != nil {
return nil, err return nil, err
@@ -395,25 +380,22 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr)) agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase( encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, agePrivKeyPassphrase)
agePrivKeyBuffer, agePrivKeyPassphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
} }
// Step 4: Get or derive the long-term private key // Step 4: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault) ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 5: Encrypt long-term private key to the new age unlocker // Step 5: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient( encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
ltPrivKeyData, ageIdentity.Recipient())
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
"failed to encrypt long-term private key to age unlocker: %w", err)
} }
// Step 6: Prepare keychain data // Step 6: Prepare keychain data
@@ -484,8 +466,16 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}, nil }, nil
} }
// validateKeychainItemName validates that a keychain item name is safe for // checkMacOSAvailable verifies that we're running on macOS
// command execution func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
}
return nil
}
// validateKeychainItemName validates that a keychain item name is safe for command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
if itemName == "" { if itemName == "" {
return fmt.Errorf("keychain item name cannot be empty") return fmt.Errorf("keychain item name cannot be empty")
+4 -9
View File
@@ -1,4 +1,4 @@
//go:build !darwin || !cgo //go:build !darwin
package secret package secret
@@ -76,13 +76,8 @@ func (k *KeychainUnlocker) Remove() error {
} }
// CreateKeychainUnlocker returns an error on non-Darwin platforms // CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) { func CreateKeychainUnlocker(
return nil, errKeychainNotSupported _ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
} ) (*KeychainUnlocker, error) {
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(
_ afero.Fs, _ VaultInterface,
) (*memguard.LockedBuffer, error) {
return nil, errKeychainNotSupported return nil, errKeychainNotSupported
} }
+6 -9
View File
@@ -1,4 +1,5 @@
//go:build darwin && cgo //go:build darwin
// +build darwin
package secret package secret
@@ -34,8 +35,7 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Test 2: Retrieve data from keychain // Test 2: Retrieve data from keychain
retrievedData, err := retrieveFromKeychain(testItemName) retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve data from keychain") require.NoError(t, err, "Failed to retrieve data from keychain")
assert.Equal(t, testData, string(retrievedData), assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
"Retrieved data doesn't match stored data")
// Test 3: Update existing item (store again with different data) // Test 3: Update existing item (store again with different data)
newTestData := "updated-test-data-67890" newTestData := "updated-test-data-67890"
@@ -48,8 +48,7 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Verify updated data // Verify updated data
retrievedData, err = retrieveFromKeychain(testItemName) retrievedData, err = retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve updated data from keychain") require.NoError(t, err, "Failed to retrieve updated data from keychain")
assert.Equal(t, newTestData, string(retrievedData), assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
"Retrieved data doesn't match updated data")
// Test 4: Delete from keychain // Test 4: Delete from keychain
err = deleteFromKeychain(testItemName) err = deleteFromKeychain(testItemName)
@@ -94,8 +93,7 @@ func TestKeychainInvalidItemName(t *testing.T) {
for _, name := range invalidNames { for _, name := range invalidNames {
err := storeInKeychain(name, testData) err := storeInKeychain(name, testData)
assert.Error(t, err, "Expected error for invalid name: %s", name) assert.Error(t, err, "Expected error for invalid name: %s", name)
assert.Contains(t, err.Error(), "invalid keychain item name", assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name)
"Error should mention invalid name for: %s", name)
} }
// Test valid names (should not error on validation) // Test valid names (should not error on validation)
@@ -182,6 +180,5 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
// This is important for cleaning up unlocker directories when the keychain item // This is important for cleaning up unlocker directories when the keychain item
// has already been removed (e.g., manually by user, or on a different machine) // has already been removed (e.g., manually by user, or on a different machine)
err := deleteFromKeychain(testItemName) err := deleteFromKeychain(testItemName)
assert.NoError(t, err, assert.NoError(t, err, "Deleting non-existent keychain item should not return an error")
"Deleting non-existent keychain item should not return an error")
} }
+34 -19
View File
@@ -19,6 +19,17 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon " + const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about" "abandon abandon abandon abandon abandon about"
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// writeTestPublicKey writes the unlocker public key and verifies it exists. // writeTestPublicKey writes the unlocker public key and verifies it exists.
func writeTestPublicKey( func writeTestPublicKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string, t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
@@ -163,7 +174,7 @@ func newTestPassphraseUnlocker(
return unlocker, ageIdentity, unlockerDir return unlocker, ageIdentity, unlockerDir
} }
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters //nolint:paralleltest // subtests share real-FS state, order matters
func TestPassphraseUnlockerWithRealFS(t *testing.T) { func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// This test uses real filesystem // This test uses real filesystem
if os.Getenv("CI") == "true" { if os.Getenv("CI") == "true" {
@@ -195,38 +206,42 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey) writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
}) })
// Set test environment variable (cleaned up automatically) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) defer passphrase.Destroy()
// Test getting identity from environment variable unlocker.Passphrase = passphrase
t.Run("GetIdentityFromEnv", func(t *testing.T) {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity from env: %v", err)
}
// Verify the identity matches what we expect // Test getting identity with the passphrase the unlocker was given,
expectedPubKey := ageIdentity.Recipient().String() // twice: using it must leave it intact for the next use
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
for range 2 {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity with passphrase: %v", err)
}
actualPubKey := identity.Recipient().String() // Verify the identity matches what we expect
if actualPubKey != expectedPubKey { expectedPubKey := ageIdentity.Recipient().String()
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey) actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
} }
}) })
// Unset the environment variable to test interactive prompt unlocker.Passphrase = nil
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
// Test getting identity from prompt (this would require mocking the // Test getting identity from prompt (this would require mocking the
// prompt). For real integration tests, we'd need a way to mock the // prompt). For real integration tests, we'd need a way to mock the
// passphrase input. Here we just verify the error is what we expect // passphrase input. Here we just verify the error is what we expect
// when no passphrase is available. // when no passphrase is available.
t.Run("GetIdentityWithoutEnv", func(t *testing.T) { t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
// This should fail since we're not in an interactive terminal // This should fail since we're not in an interactive terminal
_, err := unlocker.GetIdentity() _, err := unlocker.GetIdentity()
if err == nil { if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var") t.Errorf("Should have failed to get identity without a passphrase")
} }
}) })
+8 -18
View File
@@ -3,7 +3,6 @@ package secret
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
@@ -135,28 +134,19 @@ func (p *PassphraseUnlocker) Remove() error {
return nil return nil
} }
// getPassphrase retrieves the passphrase from memory, environment, or // getPassphrase returns a copy of p.Passphrase, or else asks the user for
// user input. Returns a LockedBuffer for secure memory handling // the passphrase. The caller must destroy the returned buffer.
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) { func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
// First check if we already have the passphrase
if p.Passphrase != nil && p.Passphrase.IsAlive() { if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID()) Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Return a copy of the passphrase buffer // Not NewBufferFromBytes, which would wipe p.Passphrase
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil passphrase := memguard.NewBuffer(p.Passphrase.Size())
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, nil
} }
Debug("No passphrase in memory, checking environment") Debug("No passphrase in memory, prompting user")
// Check environment variable for passphrase
passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return secureBuffer, nil
}
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase // Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ") secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil { if err != nil {
+325 -453
View File
@@ -4,9 +4,7 @@ package secret_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -24,24 +22,23 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// pgpUnlockerType is the type of a PGP unlocker. // Register vault with secret package for testing
const pgpUnlockerType = "pgp" func init() {
// Register the vault.GetCurrentVault function with the secret package
var errNilDataBuffer = errors.New("data buffer is nil") secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
return vault.GetCurrentVault(fs, stateDir)
})
}
// setupNonInteractiveGPG creates a custom GPG environment for testing // setupNonInteractiveGPG creates a custom GPG environment for testing
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) { func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
t.Helper()
// Create GPG config file for non-interactive operation // Create GPG config file for non-interactive operation
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf") gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
gpgConfContent := `batch gpgConfContent := `batch
no-tty no-tty
pinentry-mode loopback pinentry-mode loopback
` `
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
if err != nil {
t.Fatalf("Failed to write GPG config file: %v", err) t.Fatalf("Failed to write GPG config file: %v", err)
} }
@@ -50,15 +47,11 @@ pinentry-mode loopback
origDecryptFunc := secret.GPGDecryptFunc origDecryptFunc := secret.GPGDecryptFunc
// Set custom GPG functions for this test // Set custom GPG functions for this test
secret.GPGEncryptFunc = func( secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
data *memguard.LockedBuffer, keyID string,
) ([]byte, error) {
if data == nil { if data == nil {
return nil, errNilDataBuffer return nil, fmt.Errorf("data buffer is nil")
} }
cmd := exec.Command("gpg",
//nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
"--yes", "--yes",
@@ -70,13 +63,11 @@ pinentry-mode loopback
"-r", keyID) "-r", keyID)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(data.Bytes()) cmd.Stdin = bytes.NewReader(data.Bytes())
err := cmd.Run() if err := cmd.Run(); err != nil {
if err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String()) return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
} }
@@ -84,8 +75,7 @@ pinentry-mode loopback
} }
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) { secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
//nolint:gosec // G204: test runs gpg with test-controlled arguments cmd := exec.Command("gpg",
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
"--yes", "--yes",
@@ -95,13 +85,11 @@ pinentry-mode loopback
"--decrypt") "--decrypt")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(encryptedData) cmd.Stdin = bytes.NewReader(encryptedData)
err := cmd.Run() if err := cmd.Run(); err != nil {
if err != nil {
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String()) return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
} }
@@ -117,24 +105,20 @@ pinentry-mode loopback
} }
// runGPGWithPassphrase executes a GPG command with the specified passphrase // runGPGWithPassphrase executes a GPG command with the specified passphrase
func runGPGWithPassphrase( func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) {
ctx context.Context, cmdArgs := []string{
gnupgHome, passphrase string, args []string, input io.Reader,
) ([]byte, error) {
cmdArgs := append([]string{
"--homedir=" + gnupgHome, "--homedir=" + gnupgHome,
"--batch", "--batch",
"--yes", "--yes",
"--pinentry-mode", "loopback", "--pinentry-mode", "loopback",
"--passphrase", passphrase, "--passphrase", passphrase,
}, args...) }
cmdArgs = append(cmdArgs, args...)
//nolint:gosec // G204: test runs gpg with test-controlled arguments cmd := exec.Command("gpg", cmdArgs...)
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
cmd.Stdin = input cmd.Stdin = input
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
@@ -146,96 +130,14 @@ func runGPGWithPassphrase(
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
// generateTestGPGKey generates a GPG key protected by passphrase in
// gnupgHomeDir and returns its key ID and fingerprint.
func generateTestGPGKey(
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
) (string, string) {
t.Helper()
// Create GPG batch file for key generation
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + passphrase + `
%commit
%echo Key generation completed
`
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
if err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
if err != nil {
t.Fatalf("Failed to list GPG keys: %v", err)
}
// Parse output to get key ID and fingerprint
var keyID, fingerprint string
for line := range strings.SplitSeq(string(output), "\n") {
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
if len(fields) >= 5 {
keyID = fields[4]
}
} else if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
fingerprint = fields[9]
break
}
}
}
if keyID == "" {
t.Fatalf("Failed to find GPG key ID in output: %s", output)
}
if fingerprint == "" {
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
}
t.Logf("Generated GPG key ID: %s", keyID)
t.Logf("Generated GPG fingerprint: %s", fingerprint)
return keyID, fingerprint
}
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestPGPUnlockerWithRealFS(t *testing.T) { func TestPGPUnlockerWithRealFS(t *testing.T) {
// Check if gpg is available // Check if gpg is available
_, err := exec.LookPath("gpg") if _, err := exec.LookPath("gpg"); err != nil {
if err != nil {
t.Log("GPG not available, PGP unlock key tests may not fully function") t.Log("GPG not available, PGP unlock key tests may not fully function")
// Continue anyway to test what we can // Continue anyway to test what we can
} }
// Create a temporary directory for our tests. Not t.TempDir: its longer // Create a temporary directory for our tests
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
// macOS sets on socket paths.
//
//nolint:usetesting // see the comment above
tempDir, err := os.MkdirTemp("", "secret-pgp-test-") tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
@@ -244,9 +146,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Create a temporary GNUPGHOME // Create a temporary GNUPGHOME
gnupgHomeDir := filepath.Join(tempDir, "gnupg") gnupgHomeDir := filepath.Join(tempDir, "gnupg")
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
err = os.MkdirAll(gnupgHomeDir, 0o700)
if err != nil {
t.Fatalf("Failed to create GNUPGHOME: %v", err) t.Fatalf("Failed to create GNUPGHOME: %v", err)
} }
@@ -259,7 +159,64 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Setup non-interactive GPG with custom functions // Setup non-interactive GPG with custom functions
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir) setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase) // Create GPG batch file for key generation
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + testPassphrase + `
%commit
%echo Key generation completed
`
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
if err != nil {
t.Fatalf("Failed to list GPG keys: %v", err)
}
// Parse output to get key ID and fingerprint
var keyID, fingerprint string
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
if len(fields) >= 5 {
keyID = fields[4]
}
} else if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
fingerprint = fields[9]
break
}
}
}
if keyID == "" {
t.Fatalf("Failed to find GPG key ID in output: %s", output)
}
if fingerprint == "" {
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
}
t.Logf("Generated GPG key ID: %s", keyID)
t.Logf("Generated GPG fingerprint: %s", fingerprint)
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere // Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
t.Setenv("GPG_AGENT_INFO", "") t.Setenv("GPG_AGENT_INFO", "")
@@ -267,8 +224,13 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Test data
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
// Set test environment variables // Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvGPGKeyID, keyID) t.Setenv(secret.EnvGPGKeyID, keyID)
// Set up vault structure for testing // Set up vault structure for testing
@@ -277,20 +239,162 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Test creation of a PGP unlock key through a vault // Test creation of a PGP unlock key through a vault
t.Run("CreatePGPUnlocker", func(t *testing.T) { t.Run("CreatePGPUnlocker", func(t *testing.T) {
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint) // Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
})
defer timer.Stop()
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != "pgp" {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check if the key ID includes the GPG fingerprint
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
}
// Check if the key directory exists
unlockerDir := pgpUnlocker.GetDirectory()
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
// Read and verify metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != "pgp" {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}) })
// Set up key directory for individual tests // Set up key directory for individual tests
unlockerDir := filepath.Join(tempDir, "unlocker") unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
err = os.MkdirAll(unlockerDir, secret.DirPerms)
if err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err) t.Fatalf("Failed to create unlocker directory: %v", err)
} }
// Set up test metadata // Set up test metadata
metadata := secret.UnlockerMetadata{ metadata := secret.UnlockerMetadata{
Type: pgpUnlockerType, Type: "pgp",
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"}, Flags: []string{"gpg", "encrypted"},
} }
@@ -300,337 +404,105 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Test getting GPG key ID // Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) { t.Run("GetGPGKeyID", func(t *testing.T) {
testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint) // Create PGP metadata with GPG key ID
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
}) })
// Test getting identity from PGP unlocker // Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) { t.Run("GetIdentity", func(t *testing.T) {
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID) // Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}) })
// Test removing the unlocker // Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) { t.Run("RemoveUnlocker", func(t *testing.T) {
testRemovePGPUnlocker(t, fs, unlocker, unlockerDir) // Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}) })
} }
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
// and metadata.
func testCreatePGPUnlocker(
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
) {
t.Helper()
// Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
})
defer timer.Stop()
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != pgpUnlockerType {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check if the key ID includes the GPG fingerprint
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'",
pgpUnlocker.GetID(), fingerprint)
}
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
}
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
// its files.
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
t.Helper()
// Check if the key directory exists
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
}
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
// unlockerDir names its type and the GPG key by fingerprint.
func checkPGPUnlockerMetadata(
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
) {
t.Helper()
// Read and verify metadata
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != pgpUnlockerType {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
// into unlockerDir and checks that unlocker reads it back.
func testGetGPGKeyID(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
) {
t.Helper()
// Create PGP metadata with GPG key ID
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir, keyID string,
) {
t.Helper()
// Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
err = afero.WriteFile(fs, recipientPath,
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
func testRemovePGPUnlocker(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
) {
t.Helper()
// Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}
+15 -7
View File
@@ -254,9 +254,12 @@ func pgpUnlockerDir(
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata. // fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
// Everything that can fail short of writing a file is done before anything // Everything that can fail short of writing a file is done before anything
// is written, and the files are written through WriteDir, so a failure // is written, and the files are written through WriteDir, so a failure
// leaves no partial unlocker. // leaves no partial unlocker. The long-term key comes from mnemonic when it
// is not nil, else from the current unlocker, which is given passphrase when
// it is a passphrase unlocker.
func CreatePGPUnlocker( func CreatePGPUnlocker(
fs afero.Fs, stateDir, gpgKeyID, fingerprint string, fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*PGPUnlocker, error) { ) (*PGPUnlocker, error) {
err := checkGPGAvailable() err := checkGPGAvailable()
if err != nil { if err != nil {
@@ -268,6 +271,10 @@ func CreatePGPUnlocker(
return nil, err return nil, err
} }
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
vault.SetMnemonic(mnemonic)
vault.SetUnlockPassphrase(passphrase)
// Step 1: Generate a new age keypair for the PGP unlocker // Step 1: Generate a new age keypair for the PGP unlocker
ageIdentity, err := age.GenerateX25519Identity() ageIdentity, err := age.GenerateX25519Identity()
if err != nil { if err != nil {
@@ -277,7 +284,7 @@ func CreatePGPUnlocker(
// Step 2: Encrypt the long-term private key to the new keypair, and the // Step 2: Encrypt the long-term private key to the new keypair, and the
// keypair's private key to the GPG key // keypair's private key to the GPG key
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys( encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
fs, vault, ageIdentity, gpgKeyID) vault, ageIdentity, gpgKeyID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -316,14 +323,15 @@ func CreatePGPUnlocker(
// to the new PGP unlocker's age keypair, and that keypair's private key // to the new PGP unlocker's age keypair, and that keypair's private key
// encrypted to the GPG key gpgKeyID. // encrypted to the GPG key gpgKeyID.
func encryptPGPUnlockerKeys( func encryptPGPUnlockerKeys(
fs afero.Fs, vault VaultInterface, vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
ageIdentity *age.X25519Identity, gpgKeyID string,
) ([]byte, []byte, error) { ) ([]byte, []byte, error) {
// Get or derive the long-term private key // From the mnemonic or the current unlocker, as for a passphrase unlocker
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault) ltIdentity, err := vault.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
return nil, nil, err return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
} }
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient( encryptedLtPrivKey, err := EncryptToRecipient(
+5 -6
View File
@@ -40,15 +40,14 @@ func installFakeGPG(t *testing.T) {
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at // TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
// getting the vault's long-term key, which used to come after part of the // getting the vault's long-term key, which used to come after part of the
// unlocker was written, and asserts that nothing is written. Getting the key // unlocker was written, and asserts that nothing is written. Getting the key
// fails because on macOS there is no mnemonic and no current unlocker, and // fails because there is no mnemonic and no current unlocker.
// on every other platform it always fails //
// (https://git.eeqj.de/sneak/secret/issues/88). //nolint:paralleltest // installFakeGPG uses t.Setenv
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) { func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t) installFakeGPG(t)
t.Setenv(secret.EnvMnemonic, "")
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName) vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
require.NoError(t, err) require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error { fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -58,7 +57,7 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
}} }}
_, err = secret.CreatePGPUnlocker( _, err = secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint) fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
require.Error(t, err) require.Error(t, err)
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
+18 -11
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -35,6 +34,12 @@ type VaultInterface interface {
GetName() string GetName() string
GetFilesystem() afero.Fs GetFilesystem() afero.Fs
GetCurrentUnlocker() (Unlocker, error) GetCurrentUnlocker() (Unlocker, error)
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
// mnemonic to derive the long-term key from, and the passphrase for a
// current passphrase unlocker; nil for none.
SetMnemonic(mnemonic *memguard.LockedBuffer)
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
CreatePassphraseUnlocker( CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error) passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
} }
@@ -76,9 +81,12 @@ func NewSecret(vault VaultInterface, name string) *Secret {
} }
} }
// GetValue retrieves and decrypts the current version's value using the // GetValue retrieves and decrypts the current version's value, with the
// provided unlocker // vault's long-term key derived from mnemonic when it is not nil, else
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) { // obtained through unlocker
func (s *Secret) GetValue(
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value", DebugWith("Getting secret value",
slog.String("secret_name", s.Name), slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()), slog.String("vault_name", s.vault.GetName()),
@@ -113,9 +121,8 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// Create version object // Create version object
version := NewVersion(s.vault, s.Name, currentVersion) version := NewVersion(s.vault, s.Name, currentVersion)
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption if mnemonic != nil {
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" { return s.getValueViaMnemonic(version, mnemonic.String())
return s.getValueViaMnemonic(version, envMnemonic)
} }
Debug("Using unlocker for vault access", "secret_name", s.Name) Debug("Using unlocker for vault access", "secret_name", s.Name)
@@ -209,11 +216,11 @@ func (s *Secret) Exists() (bool, error) {
} }
// getValueViaMnemonic derives the vault's long-term key from the // getValueViaMnemonic derives the vault's long-term key from the
// mnemonic in the environment and decrypts the version value with it. // mnemonic and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic( func (s *Secret) getValueViaMnemonic(
version *Version, envMnemonic string, version *Version, mnemonic string,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic from environment for direct long-term key derivation", Debug("Using mnemonic for direct long-term key derivation",
"secret_name", s.Name) "secret_name", s.Name)
// Get vault directory to read metadata // Get vault directory to read metadata
@@ -250,7 +257,7 @@ func (s *Secret) getValueViaMnemonic(
) )
// Use mnemonic with the vault's derivation index from metadata // Use mnemonic with the vault's derivation index from metadata
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil { if err != nil {
Debug("Failed to derive long-term key from mnemonic for secret", Debug("Failed to derive long-term key from mnemonic for secret",
"error", err, "secret_name", s.Name) "error", err, "secret_name", s.Name)
+54 -23
View File
@@ -2,6 +2,7 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
@@ -22,7 +23,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about" "abandon abandon abandon abandon abandon about"
var ( var (
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set") errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock") errNotImplementedInMock = errors.New("not implemented in mock")
) )
@@ -32,6 +33,7 @@ type MockVault struct {
fs afero.Fs fs afero.Fs
directory string directory string
derivationIndex uint32 derivationIndex uint32
mnemonic *memguard.LockedBuffer
} }
func (m *MockVault) GetDirectory() (string, error) { func (m *MockVault) GetDirectory() (string, error) {
@@ -61,12 +63,11 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
ltPubKeyPath := filepath.Join(m.directory, "pub.age") ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index // Derive long-term key using the vault's derivation index
mnemonic := os.Getenv(EnvMnemonic) if m.mnemonic == nil {
if mnemonic == "" {
return errMnemonicNotSet return errMnemonicNotSet
} }
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex) ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil { if err != nil {
return err return err
} }
@@ -107,6 +108,16 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, errNotImplementedInMock return nil, errNotImplementedInMock
} }
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
m.mnemonic = mnemonic
}
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVault) CreatePassphraseUnlocker( func (m *MockVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer, _ *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) { ) (*PassphraseUnlocker, error) {
@@ -234,13 +245,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
} }
} }
//nolint:paralleltest // uses t.Setenv (process-global environment) //nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) { func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing // Create an in-memory filesystem for testing
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set test mnemonic for direct encryption/decryption mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
t.Setenv(EnvMnemonic, testMnemonicValue) defer mnemonic.Destroy()
// Set up a test vault structure // Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret" baseDir := "/test-config/berlin.sneak.pkg.secret"
@@ -254,6 +265,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
fs: fs, fs: fs,
directory: vaultDir, directory: vaultDir,
derivationIndex: 0, derivationIndex: 0,
mnemonic: mnemonic,
} }
// Test data // Test data
@@ -310,26 +322,45 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
}) })
} }
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) { // TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
// This test demonstrates the bug where GetValue uses hardcoded index 0 // GetValue, given the mnemonic, derives the long-term key at the derivation
// instead of the vault's actual derivation index when using environment mnemonic // index in the vault's metadata. At index 0 it could not decrypt the secret,
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
// Set up test mnemonic fs := afero.NewMemMapFs()
t.Setenv(EnvMnemonic, testMnemonicValue) vaultDir := "/test-config/vaults.d/test-vault"
// Create temporary directory for vaults mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
fs := afero.NewOsFs() defer mnemonic.Destroy()
tempDir, err := afero.TempDir(fs, "", "secret-test-")
vlt := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 1,
mnemonic: mnemonic,
}
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
require.NoError(t, err)
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
err = afero.WriteFile(
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
require.NoError(t, err) require.NoError(t, err)
defer func() { secretName, secretValue := "x", "value"
_ = fs.RemoveAll(tempDir)
}()
stateDir := filepath.Join(tempDir, ".secret") err = vlt.AddSecret(secretName,
require.NoError(t, fs.MkdirAll(stateDir, 0o700)) memguard.NewBufferFromBytes([]byte(secretValue)), false)
require.NoError(t, err)
// This test is now in the integration test file where it can use real vaults value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex require.NoError(t, err)
t.Log("This test demonstrates the bug in the integration test file")
defer value.Destroy()
require.Equal(t, secretValue, value.String())
} }
+50 -79
View File
@@ -1,15 +1,14 @@
//go:build darwin //go:build darwin
// +build darwin
package secret package secret
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"time" "time"
"filippo.io/age" "filippo.io/age"
@@ -30,12 +29,9 @@ const (
seLongtermFilename = "longterm.age.se" seLongtermFilename = "longterm.age.se"
) )
var errNotMacOS = errors.New("keychain unlockers are only supported on macOS")
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data. // SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct { type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"` SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"` SEKeyHash string `json:"seKeyHash"`
} }
@@ -47,19 +43,6 @@ type SecureEnclaveUnlocker struct {
fs afero.Fs fs afero.Fs
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for SE-based unlockers. // GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave. // Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
@@ -75,7 +58,6 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Read ECIES-encrypted long-term private key from disk // Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename) encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath) encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -158,9 +140,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
if seKeyHash != "" { if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash) Debug("Deleting SE key", "hash", seKeyHash)
if err := macse.DeleteKey(seKeyHash); err != nil {
err = macse.DeleteKey(seKeyHash)
if err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash) Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err) return fmt.Errorf("failed to delete SE key: %w", err)
@@ -168,9 +148,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
} }
Debug("Removing SE unlocker directory", "directory", s.Directory) Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
err = RemoveDirAtomic(s.fs, s.Directory)
if err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err) return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
} }
@@ -180,24 +158,34 @@ func (s *SecureEnclaveUnlocker) Remove() error {
} }
// getSEKeyInfo reads the SE key label and hash from metadata. // getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) { func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json") metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath) metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err) return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
} }
var seMetadata SecureEnclaveUnlockerMetadata var seMetadata SecureEnclaveUnlockerMetadata
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
err = json.Unmarshal(metadataData, &seMetadata)
if err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err) return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
} }
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil 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. // generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) { func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -216,25 +204,17 @@ func generateSEKeyLabel(vaultName string) (string, error) {
), nil ), nil
} }
// checkMacOSAvailable verifies that we're running on macOS. The keychain
// unlocker uses it too.
func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
}
return nil
}
// CreateSecureEnclaveUnlocker creates a new SE unlocker. // CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave // The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used. // using ECIES. No intermediate age keypair is used.
// The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes.
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(
fs afero.Fs, fs afero.Fs,
stateDir string, stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
err := checkMacOSAvailable() if err := checkMacOSAvailable(); err != nil {
if err != nil {
return nil, err return nil, err
} }
@@ -251,7 +231,6 @@ func CreateSecureEnclaveUnlocker(
// Step 1: Create P-256 key in the Secure Enclave via sc_auth // Step 1: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel) Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel) _, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err) return nil, fmt.Errorf("failed to create SE key: %w", err)
@@ -260,7 +239,7 @@ func CreateSecureEnclaveUnlocker(
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash) Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key // Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault) ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"failed to get long-term private key: %w", "failed to get long-term private key: %w",
@@ -284,7 +263,7 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockerDirName := "se-" + filepath.Base(seKeyLabel) unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
seMetadata := SecureEnclaveUnlockerMetadata{ seMetadata := SecureEnclaveUnlockerMetadata{
@@ -304,7 +283,20 @@ func CreateSecureEnclaveUnlocker(
// Step 5: Write the SE-encrypted long-term key, then the metadata // Step 5: Write the SE-encrypted long-term key, then the metadata
err = WriteDir(fs, unlockerDir, func(dir string) error { err = WriteDir(fs, unlockerDir, func(dir string) error {
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes) ltKeyPath := filepath.Join(dir, seLongtermFilename)
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -317,37 +309,15 @@ func CreateSecureEnclaveUnlocker(
}, nil }, nil
} }
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the // getLongTermKeyForSE retrieves the vault's long-term private key, derived
// SE-encrypted long-term key, then the metadata. // from mnemonic when it is not nil, else through the current unlocker, which
func writeSEUnlockerFiles( // is given passphrase when it is a passphrase unlocker.
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
encryptedLtKey)
if err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return 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( func getLongTermKeyForSE(
fs afero.Fs, fs afero.Fs,
vault VaultInterface, vault VaultInterface,
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic) if mnemonic != nil {
if envMnemonic != "" {
// Read vault metadata to get the correct derivation index // Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory() vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
@@ -355,24 +325,22 @@ func getLongTermKeyForSE(
} }
metadataPath := filepath.Join(vaultDir, "vault-metadata.json") metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath) metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err) return nil, fmt.Errorf("failed to read vault metadata: %w", err)
} }
var metadata VaultMetadata var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err) return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
} }
// Use mnemonic with the vault's actual derivation index // Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity( ltIdentity, err := agehd.DeriveIdentity(
envMnemonic, mnemonic.String(),
metadata.DerivationIndex, metadata.DerivationIndex,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", "failed to derive long-term key from mnemonic: %w",
@@ -388,6 +356,10 @@ func getLongTermKeyForSE(
return nil, fmt.Errorf("failed to get current unlocker: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
currentIdentity, err := currentUnlocker.GetIdentity() currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -401,7 +373,6 @@ func getLongTermKeyForSE(
currentUnlocker.GetDirectory(), currentUnlocker.GetDirectory(),
"longterm.age", "longterm.age",
) )
encryptedLtKey, err := afero.ReadFile(fs, longtermPath) encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
+2
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -80,6 +81,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(
_ afero.Fs, _ afero.Fs,
_ string, _ string,
_, _ *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported return nil, errSENotSupported
} }
+1 -1
View File
@@ -78,7 +78,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test") unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
assert.Nil(t, unlocker) assert.Nil(t, unlocker)
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported) require.ErrorIs(t, err, errSENotSupported)
+8 -20
View File
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
// +build darwin
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
package secret package secret
import ( import (
@@ -13,14 +13,12 @@ import (
) )
func TestNewSecureEnclaveUnlocker(t *testing.T) { func TestNewSecureEnclaveUnlocker(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker" dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: seUnlockerType, Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{seUnlockerType, "macos"}, Flags: []string{"secure-enclave", "macos"},
} }
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata) unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
@@ -37,11 +35,9 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
} }
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) { func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: seUnlockerType, Type: "secure-enclave",
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
} }
@@ -52,11 +48,9 @@ func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
} }
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) { func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: seUnlockerType, Type: "secure-enclave",
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
} }
@@ -69,8 +63,6 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
} }
func TestGenerateSEKeyLabel(t *testing.T) { func TestGenerateSEKeyLabel(t *testing.T) {
t.Parallel()
label, err := generateSEKeyLabel("test-vault") label, err := generateSEKeyLabel("test-vault")
require.NoError(t, err) require.NoError(t, err)
@@ -80,8 +72,6 @@ func TestGenerateSEKeyLabel(t *testing.T) {
} }
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) { func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker-missing" dir := "/tmp/test-se-unlocker-missing"
@@ -94,12 +84,10 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
"seKeyLabel": "berlin.sneak.app.secret.se.test", "seKeyLabel": "berlin.sneak.app.secret.se.test",
"seKeyHash": "abc123" "seKeyHash": "abc123"
}` }`
require.NoError(t, afero.WriteFile( require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms))
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
))
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: seUnlockerType, Type: "secure-enclave",
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
} }
@@ -108,6 +96,6 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
// GetIdentity should fail because the encrypted longterm key file is missing // GetIdentity should fail because the encrypted longterm key file is missing
identity, err := unlocker.GetIdentity() identity, err := unlocker.GetIdentity()
assert.Nil(t, identity) assert.Nil(t, identity)
require.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key") assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
} }
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build darwin && cgo //go:build darwin
package secret package secret
+8
View File
@@ -87,6 +87,14 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
return nil, errNotImplementedInMock return nil, errNotImplementedInMock
} }
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) CreatePassphraseUnlocker( func (m *MockVersionVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer, _ *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) { ) (*secret.PassphraseUnlocker, error) {
+25 -20
View File
@@ -8,7 +8,6 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -41,46 +40,49 @@ func deriveVaultIdentity(
return ltIdentity return ltIdentity
} }
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestVaultWithRealFilesystem(t *testing.T) { func TestVaultWithRealFilesystem(t *testing.T) {
t.Parallel()
// Create a temporary directory for our tests // Create a temporary directory for our tests
tempDir := t.TempDir() tempDir := t.TempDir()
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Test currentvault file handling (plain file with relative path) // Test currentvault file handling (plain file with relative path)
t.Run("CurrentVaultFileHandling", func(t *testing.T) { t.Run("CurrentVaultFileHandling", func(t *testing.T) {
t.Parallel()
testCurrentVaultFileHandling(t, fs, tempDir) testCurrentVaultFileHandling(t, fs, tempDir)
}) })
// Test secret operations with deeply nested paths // Test secret operations with deeply nested paths
t.Run("DeepPathSecrets", func(t *testing.T) { t.Run("DeepPathSecrets", func(t *testing.T) {
t.Parallel()
testDeepPathSecrets(t, fs, tempDir) testDeepPathSecrets(t, fs, tempDir)
}) })
// Test key caching in GetOrDeriveLongTermKey // Test key caching in GetOrDeriveLongTermKey
t.Run("KeyCaching", func(t *testing.T) { t.Run("KeyCaching", func(t *testing.T) {
t.Parallel()
testKeyCaching(t, fs, tempDir) testKeyCaching(t, fs, tempDir)
}) })
// Test vault name validation // Test vault name validation
t.Run("VaultNameValidation", func(t *testing.T) { t.Run("VaultNameValidation", func(t *testing.T) {
t.Parallel()
testVaultNameValidation(t, fs, tempDir) testVaultNameValidation(t, fs, tempDir)
}) })
// Test multiple vaults and switching between them // Test multiple vaults and switching between them
t.Run("MultipleVaults", func(t *testing.T) { t.Run("MultipleVaults", func(t *testing.T) {
t.Parallel()
testMultipleVaults(t, fs, tempDir) testMultipleVaults(t, fs, tempDir)
}) })
// Test adding a secret in one vault and verifying it's not visible in // Test adding a secret in one vault and verifying it's not visible in
// another // another
t.Run("VaultIsolation", func(t *testing.T) { t.Run("VaultIsolation", func(t *testing.T) {
t.Parallel()
testVaultIsolation(t, fs, tempDir) testVaultIsolation(t, fs, tempDir)
}) })
} }
@@ -96,7 +98,8 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
} }
// Create a test vault // Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName) vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -141,9 +144,10 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault - CreateVault now handles public key when // Create a test vault - CreateVault writes the public key derived from
// mnemonic is in env // the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName) vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -216,9 +220,10 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault - CreateVault now handles public key when // Create a test vault - CreateVault writes the public key derived from
// mnemonic is in env // the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName) vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -319,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
} }
for _, name := range validNames { for _, name := range validNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Errorf("Failed to create vault with valid name %q: %v", name, err) t.Errorf("Failed to create vault with valid name %q: %v", name, err)
} }
@@ -335,7 +340,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
} }
for _, name := range invalidNames { for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err == nil { if err == nil {
t.Errorf("Expected error creating vault with invalid name %q, "+ t.Errorf("Expected error creating vault with invalid name %q, "+
"but got none", name) "but got none", name)
@@ -356,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
// Create three vaults // Create three vaults
vaultNames := []string{"vault1", "vault2", "vault3"} vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames { for _, name := range vaultNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err) t.Fatalf("Failed to create vault %s: %v", name, err)
} }
@@ -404,14 +409,14 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create two vaults - CreateVault now handles public key when mnemonic // Create two vaults - CreateVault writes the public key derived from
// is in env // the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1") vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault1: %v", err) t.Fatalf("Failed to create vault1: %v", err)
} }
vault2, err := vault.CreateVault(fs, stateDir, "vault2") vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault2: %v", err) t.Fatalf("Failed to create vault2: %v", err)
} }
+9 -10
View File
@@ -44,15 +44,12 @@ var errUnexpectedValue = errors.New("unexpected value")
// TestVersionIntegrationWorkflow tests the complete version workflow // TestVersionIntegrationWorkflow tests the complete version workflow
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // the subtests are steps that build on each other
func TestVersionIntegrationWorkflow(t *testing.T) { func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set mnemonic for testing // Create vault without a long-term key, which is set up below
t.Setenv(secret.EnvMnemonic, testMnemonic) vault, err := CreateVault(fs, testStateDir, "test", nil)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -351,9 +348,9 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
} }
// TestVersionConcurrency tests concurrent version operations // TestVersionConcurrency tests concurrent version operations
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionConcurrency(t *testing.T) { func TestVersionConcurrency(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set up vault // Set up vault
@@ -366,6 +363,8 @@ func TestVersionConcurrency(t *testing.T) {
// Test concurrent reads // Test concurrent reads
t.Run("concurrent_reads", func(t *testing.T) { t.Run("concurrent_reads", func(t *testing.T) {
t.Parallel()
done := make(chan bool, 10) done := make(chan bool, 10)
errCh := make(chan error, 10) errCh := make(chan error, 10)
@@ -403,9 +402,9 @@ func TestVersionConcurrency(t *testing.T) {
} }
// TestVersionCompatibility tests that old secrets without versions still work // TestVersionCompatibility tests that old secrets without versions still work
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionCompatibility(t *testing.T) { func TestVersionCompatibility(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set up vault // Set up vault
+23 -16
View File
@@ -3,7 +3,6 @@ package vault
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -11,6 +10,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -155,19 +155,18 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
// It returns the derivation index, public key hash, and family hash. // It returns the derivation index, public key hash, and family hash.
func processMnemonicForVault( func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string, fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (uint32, string, string, error) { ) (uint32, string, string, error) {
// Check if mnemonic is available in environment if mnemonicBuffer == nil {
mnemonic := os.Getenv(secret.EnvMnemonic) secret.Debug("No mnemonic given, vault created without long-term key",
if mnemonic == "" {
secret.Debug("No mnemonic in environment, vault created without long-term key",
"vault", vaultName) "vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided // Use 0 for derivation index when no mnemonic is provided
return 0, "", "", nil return 0, "", "", nil
} }
secret.Debug("Mnemonic found in environment, deriving long-term key", mnemonic := mnemonicBuffer.String()
"vault", vaultName)
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
// Get the next available derivation index for this mnemonic // Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic) derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
@@ -208,12 +207,17 @@ func processMnemonicForVault(
return derivationIndex, publicKeyHash, familyHash, nil return derivationIndex, publicKeyHash, familyHash, nil
} }
// CreateVault creates a new vault and selects it as the current vault. It // CreateVault creates a new vault and selects it as the current vault. When
// refuses a vault that already exists before writing anything: creating it // mnemonic is not nil, the vault's long-term key is derived from it, and the
// again would replace its keys, and its secrets could no longer be // returned vault has it as its Mnemonic; when it is nil, the vault has no
// decrypted. The commands that call it hold the state directory lock, so no // long-term key until one is imported. It refuses a vault that already
// other command can create the vault between the check and the writes. // exists before writing anything: creating it again would replace its keys,
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) { // and its secrets could no longer be decrypted. The commands that call it
// hold the state directory lock, so no other command can create the vault
// between the check and the writes.
func CreateVault(
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
err := ValidateVaultName(name) err := ValidateVaultName(name)
@@ -263,7 +267,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Process mnemonic if available // Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault( derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name) fs, stateDir, vaultDir, name, mnemonic)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -292,7 +296,10 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Create and return the vault // Create and return the vault
secret.Debug("Successfully created vault", "name", name) secret.Debug("Successfully created vault", "name", name)
return NewVault(fs, stateDir, name), nil vlt := NewVault(fs, stateDir, name)
vlt.Mnemonic = mnemonic
return vlt, nil
} }
// SelectVault selects the given vault as the current vault // SelectVault selects the given vault as the current vault
+6 -10
View File
@@ -297,14 +297,14 @@ func TestSampleHashCalculation(t *testing.T) {
} }
func TestWorkflowMismatch(t *testing.T) { func TestWorkflowMismatch(t *testing.T) {
t.Parallel()
// Create a temporary directory for testing // Create a temporary directory for testing
tempDir := t.TempDir() tempDir := t.TempDir()
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command) // Test Case 1: Create vault WITH mnemonic (like init command)
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic) _, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
_, err := vault.CreateVault(fs, tempDir, "default")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault with mnemonic: %v", err) t.Fatalf("Failed to create vault with mnemonic: %v", err)
} }
@@ -321,19 +321,15 @@ func TestWorkflowMismatch(t *testing.T) {
metadata1.DerivationIndex, metadata1.PublicKeyHash) metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault) // Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
t.Setenv("SB_SECRET_MNEMONIC", "") _, err = vault.CreateVault(fs, tempDir, "work", nil)
_, err = vault.CreateVault(fs, tempDir, "work")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault without mnemonic: %v", err) t.Fatalf("Failed to create vault without mnemonic: %v", err)
} }
vault2Dir := filepath.Join(tempDir, "vaults.d", "work") vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
// Simulate the vault import process // Simulate the vault import process: get the next available derivation
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic) // index for this mnemonic
// Get the next available derivation index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic) derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
if err != nil { if err != nil {
t.Fatalf("Failed to get next derivation index: %v", err) t.Fatalf("Failed to get next derivation index: %v", err)
+13 -14
View File
@@ -3,7 +3,6 @@ package vault_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -13,15 +12,13 @@ import (
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion // TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts. // validates the secret name and rejects path traversal attempts.
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13 // This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) { func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
// Add a legitimate secret so the vault is set up // Add a legitimate secret so the vault is set up
@@ -41,6 +38,8 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames { for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretVersion(name, "") _, err := vlt.GetSecretVersion(name, "")
require.Error(t, err, require.Error(t, err,
"GetSecretVersion should reject malicious name: %s", name) "GetSecretVersion should reject malicious name: %s", name)
@@ -53,12 +52,12 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls // TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
// GetSecretVersion) also rejects path traversal names. // GetSecretVersion) also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) { func TestGetSecretRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd") _, err = vlt.GetSecret("../../../etc/passwd")
@@ -68,15 +67,13 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject // TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts. // also validates names and rejects path traversal attempts.
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) { func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
maliciousNames := []string{ maliciousNames := []string{
@@ -87,6 +84,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames { for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretObject(name) _, err := vlt.GetSecretObject(name)
require.Error(t, err, "GetSecretObject should reject: %s", name) require.Error(t, err, "GetSecretObject should reject: %s", name)
require.Contains(t, err.Error(), "invalid secret name") require.Contains(t, err.Error(), "invalid secret name")
+14 -19
View File
@@ -41,14 +41,6 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " + const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon about" "abandon abandon abandon abandon about"
// envTestMnemonic is the (deliberately different) mnemonic placed in the
// environment; the vault is unlocked manually with the derived key in
// createTestVaultWithKey.
//
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon about"
// Shared fixtures for white-box tests in this package. // Shared fixtures for white-box tests in this package.
const ( const (
testStateDir = "/test/state" testStateDir = "/test/state"
@@ -73,11 +65,8 @@ func addTestSecretToVault(
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault { func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper() t.Helper()
// Set mnemonic for testing // Create vault without a long-term key, which is set up below
t.Setenv(secret.EnvMnemonic, envTestMnemonic) vault, err := CreateVault(fs, testStateDir, "test", nil)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -98,8 +87,9 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
return vault return vault
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretCreatesVersion(t *testing.T) { func TestVaultAddSecretCreatesVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -137,8 +127,9 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
assert.Equal(t, expectedValue, retrievedValue.Bytes()) assert.Equal(t, expectedValue, retrievedValue.Bytes())
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretMultipleVersions(t *testing.T) { func TestVaultAddSecretMultipleVersions(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -174,8 +165,9 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
assert.Equal(t, []byte("version-2"), value.Bytes()) assert.Equal(t, []byte("version-2"), value.Bytes())
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetSecretVersion(t *testing.T) { func TestVaultGetSecretVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -220,8 +212,9 @@ func TestVaultGetSecretVersion(t *testing.T) {
require.ErrorIs(t, err, ErrVersionNotFound) require.ErrorIs(t, err, ErrVersionNotFound)
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultVersionTimestamps(t *testing.T) { func TestVaultVersionTimestamps(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -303,8 +296,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetNonExistentVersion(t *testing.T) { func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -319,8 +313,9 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestUpdateVersionMetadata(t *testing.T) { func TestUpdateVersionMetadata(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
+3 -1
View File
@@ -70,7 +70,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.Debug("Creating passphrase unlocker instance", secret.Debug("Creating passphrase unlocker instance",
"unlocker_type", metadata.Type) "unlocker_type", metadata.Type)
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
passphraseUnlocker.Passphrase = v.UnlockPassphrase
unlocker = passphraseUnlocker
case "pgp": case "pgp":
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type) secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
+25 -7
View File
@@ -3,12 +3,12 @@ package vault
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -18,6 +18,13 @@ type Vault struct {
fs afero.Fs fs afero.Fs
stateDir string stateDir string
longTermKey *age.X25519Identity // In-memory long-term key when unlocked longTermKey *age.X25519Identity // In-memory long-term key when unlocked
// Mnemonic, when not nil, is what the long-term key is derived from
// instead of the current unlocker. The caller destroys it.
Mnemonic *memguard.LockedBuffer
// UnlockPassphrase, when not nil, is given to the current unlocker
// when that is a passphrase unlocker, which otherwise prompts for it.
// The caller destroys it.
UnlockPassphrase *memguard.LockedBuffer
} }
// NewVault creates a new Vault instance // NewVault creates a new Vault instance
@@ -56,6 +63,18 @@ func (v *Vault) ClearLongTermKey() {
v.longTermKey = nil v.longTermKey = nil
} }
// SetMnemonic sets v.Mnemonic, for code that has v only as a
// secret.VaultInterface.
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
v.Mnemonic = mnemonic
}
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
// a secret.VaultInterface.
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
v.UnlockPassphrase = passphrase
}
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it // GetOrDeriveLongTermKey gets the long-term key from memory or derives it
// from available sources // from available sources
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) { func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
@@ -66,9 +85,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name) secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
// Try to derive from environment mnemonic first if v.Mnemonic != nil {
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" { return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
} }
// No mnemonic available, try to use current unlocker // No mnemonic available, try to use current unlocker
@@ -181,9 +199,9 @@ func (v *Vault) NumSecrets() (int, error) {
// deriveLongTermKeyFromMnemonic derives the long-term key from the given // deriveLongTermKeyFromMnemonic derives the long-term key from the given
// mnemonic, verifies it against the vault metadata, and caches it in memory. // mnemonic, verifies it against the vault metadata, and caches it in memory.
func (v *Vault) deriveLongTermKeyFromMnemonic( func (v *Vault) deriveLongTermKeyFromMnemonic(
envMnemonic string, mnemonic string,
) (*age.X25519Identity, error) { ) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic from environment for long-term key derivation", secret.Debug("Using mnemonic for long-term key derivation",
"vault_name", v.Name) "vault_name", v.Name)
// Load vault metadata to get the derivation index // Load vault metadata to get the derivation index
@@ -199,7 +217,7 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
return nil, fmt.Errorf("failed to load vault metadata: %w", err) return nil, fmt.Errorf("failed to load vault metadata: %w", err)
} }
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil { if err != nil {
secret.Debug("Failed to derive long-term key from mnemonic", secret.Debug("Failed to derive long-term key from mnemonic",
"error", err, "vault_name", v.Name) "error", err, "vault_name", v.Name)
+19 -10
View File
@@ -27,12 +27,19 @@ const (
testPassphrase = "test-passphrase" testPassphrase = "test-passphrase"
) )
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel // testMnemonicBuffer returns testMnemonic in a locked buffer that is
func TestVaultOperations(t *testing.T) { // destroyed when the test ends.
// Test environment will be cleaned up automatically by t.Setenv func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Helper()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
//nolint:paralleltest // order-dependent subtests forbid parallel
func TestVaultOperations(t *testing.T) {
// Use in-memory filesystem // Use in-memory filesystem
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
@@ -64,7 +71,8 @@ func TestVaultOperations(t *testing.T) {
func testCreateVault(t *testing.T, fs afero.Fs) { func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper() t.Helper()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -221,6 +229,8 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
} }
// Test vault unlocking (should happen automatically via mnemonic) // Test vault unlocking (should happen automatically via mnemonic)
vlt.Mnemonic = testMnemonicBuffer(t)
if vlt.Locked() { if vlt.Locked() {
_, err := vlt.UnlockVault() _, err := vlt.UnlockVault()
if err != nil { if err != nil {
@@ -281,15 +291,14 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
} }
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) { func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Set test environment variables t.Parallel()
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Use in-memory filesystem // Use in-memory filesystem
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault // Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
+3 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, lint-darwin, fmt-check). Our # script/check: run all checks (test, lint, fmt-check). Our own
# own extension to scripts-to-rule-them-all. Must not modify any files. # extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -8,7 +9,6 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/lint-darwin"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }
-25
View File
@@ -1,25 +0,0 @@
#!/bin/sh
# script/lint-darwin: type-check (go vet) and lint the code as a macOS
# build compiles it, from Linux, in docker only. CI runs on Linux, which
# never compiles the files built only for macOS. Builds the lint-darwin
# stage of Dockerfile.lint, rebuilt on every run as script/lint does.
#
# Cgo is off: compiling cgo code for macOS needs Apple's SDK headers. That
# leaves out the files built only with cgo on macOS: the keychain unlocker
# (keychainunlocker.go and its tests) and the Secure Enclave bindings
# (internal/macse). Nothing on Linux checks those.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
--progress=plain \
--target lint-darwin \
--no-cache-filter=lint-darwin \
--output=type=cacheonly \
-f Dockerfile.lint .
}
main "$@"