Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 6fc947c23f Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 1s
script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
and keychainunlocker.go uses go-keychain, which is cgo there, so it and
its tests are now built only with cgo on macOS, like internal/macse;
their stubs serve a macOS build without cgo. checkMacOSAvailable moves
to seunlocker_darwin.go. The findings in the newly checked files are
fixed, and lines over 88 columns in the unchecked ones are wrapped.

Model: opus-5-5
2026-10-04 12:08:28 +00:00
51 changed files with 889 additions and 1588 deletions
+2 -66
View File
@@ -10,20 +10,14 @@ 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
@@ -34,64 +28,6 @@ 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
+6 -26
View File
@@ -197,9 +197,6 @@ Creates a new unlocker of the specified type:
**Options:** **Options:**
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified) - `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
A vault has one passphrase unlocker: adding one replaces the one the vault
has, which is removed only once the new one is the current unlocker.
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑 #### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command **DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
@@ -246,8 +243,8 @@ Decrypts data using an Age key stored as a secret.
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlockers.d/ │ │ ├── unlockers.d/
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker │ │ │ ├── passphrase/ # Passphrase unlocker
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker │ │ │ └── pgp/ # PGP unlocker
│ │ ├── secrets.d/ │ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key │ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/ │ │ │ │ ├── versions/
@@ -263,7 +260,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ └── current -> versions/20231215.001 │ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata │ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key │ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker # Current unlocker's directory name │ │ └── current-unlocker -> ../unlockers.d/passphrase
│ └── work/ │ └── work/
│ ├── unlockers.d/ │ ├── unlockers.d/
│ ├── secrets.d/ │ ├── secrets.d/
@@ -321,18 +318,6 @@ 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
@@ -472,10 +457,6 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
- **macOS**: Full support including Keychain and Secure Enclave integration - **macOS**: Full support including Keychain and Secure Enclave integration
- **Linux**: Full support (excluding macOS-specific features) - **Linux**: Full support (excluding macOS-specific features)
The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS
build without cgo, such as one cross-compiled from Linux, offers them but fails
to add or use them.
## Security Considerations ## Security Considerations
### Threat Model ### Threat Model
@@ -540,10 +521,9 @@ them. We provide:
call, also on an unchanged tree call, also on an unchanged tree
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on - `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 the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
build never compiles; cgo is off, so the keychain unlocker's calls into build never compiles; cgo is off, so the keychain unlocker
the keychain (`internal/secret/keychainunlocker_cgo.go`, and (`internal/secret/keychainunlocker.go` and its tests) and the Secure
`keychainunlocker_test.go`) and the Secure Enclave bindings Enclave bindings (`internal/macse`) are not checked
(`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`,
+31 -68
View File
@@ -28,67 +28,19 @@ Bring the repo into policy compliance in one commit:
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and - 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it `golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (`GOOS=darwin`), with cgo off
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and (https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it,
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too. and the `Dockerfile` lint stage runs its commands, so `script/cibuild`
Before, CI on Linux never compiled the files built only for macOS. Compiling does too. Before, CI on Linux never compiled the files built only for
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and macOS. Compiling cgo code for macOS needs Apple's SDK headers, and both
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions `internal/macse` and `github.com/keybase/go-keychain`, which
that call `go-keychain` moved from `keychainunlocker.go` to `keychainunlocker.go` uses, are cgo on macOS. So `keychainunlocker.go`
`keychainunlocker_cgo.go`, built only with cgo on macOS like and its tests are now built only with cgo on macOS, like
`macse_darwin.go`. A macOS build without cgo, which before did not compile, `macse_darwin.go`, and the keychain and `macse` stubs serve a macOS
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors build without cgo, which before did not compile. `checkMacOSAvailable`
say the keychain or Secure Enclave needs a macOS build with cgo. The check moved to `seunlocker_darwin.go`. The check covers the Secure Enclave
covers the rest of the keychain unlocker, the Secure Enclave unlocker and unlocker and the macOS-only tests `seunlocker_test.go` and
the macOS-only tests other than `keychainunlocker_test.go`, whose lint `pgpunlock_test.go`, whose lint findings are fixed; lines over 88
findings are fixed. For the length and complexity limits, parts of columns in the macOS files it cannot check are wrapped.
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
into functions of their own, and the Secure Enclave unlocker derives the
long-term key from the mnemonic through the same function as the keychain
unlocker instead of a copy of it. Lines over 88 columns in the files the
check cannot see are wrapped.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
Enclave unlocker the keychain item or Secure Enclave key, which names the
directory, carries the time instead of the day. `secret.WriteDir` fails on a
directory that exists instead of writing into it. `unlocker add passphrase`
writes the new unlocker, makes it current, and only then removes the vault's
other passphrase unlockers; a crash between the last two steps leaves the old
one beside the new, and the old passphrase still opens the vault through it
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
another of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
unset at once, so that no program the command runs, `gpg` included,
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
the command reads the environment; the buffers are passed down:
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
constructors take both. `CreatePGPUnlocker` sets both on the vault it
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
`vault create` no longer put the mnemonic into the environment. Unsetting
erases nothing: the starting environment (`/proc/<pid>/environ`) and
memory still hold the value. The README warns against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from
`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`
@@ -114,7 +66,9 @@ Bring the repo into policy compliance in one commit:
and encrypt everything before writing anything. All four unlocker and encrypt everything before writing anything. All four unlocker
types write their files through `secret.WriteDir`: a new unlocker is types write their files through `secret.WriteDir`: a new unlocker is
built in a temporary directory, renamed into place when complete and built in a temporary directory, renamed into place when complete and
removed on a failure. removed on a failure. One added under the directory name of an
existing unlocker is still written into that directory in place
(https://git.eeqj.de/sneak/secret/issues/71).
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` - 2026-10-04: `secret unlocker select` and `secret unlocker remove`
skip, with the warning `unlocker list` gives, an unlocker directory skip, with the warning `unlocker list` gives, an unlocker directory
whose metadata file cannot be checked for, read or parsed, instead of whose metadata file cannot be checked for, read or parsed, instead of
@@ -210,6 +164,12 @@ Bring the repo into policy compliance in one commit:
into place, and removals rename out of the way first, so a version into place, and removals rename out of the way first, so a version
or secret is never half-added and never half-removed. An or secret is never half-added and never half-removed. An
interrupted command can still leave: interrupted command can still leave:
- a broken unlocker, when it was replacing one: an unlocker added
under the directory name of an existing one is rewritten file by
file. That happens to a passphrase unlocker added to a vault that
has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71);
- from `init` or `vault create` killed after the passphrase prompt - from `init` or `vault create` killed after the passphrase prompt
but before the unlocker is written, a vault with no unlocker, but before the unlocker is written, a vault with no unlocker,
which `vault create` has already made the current vault; which `vault create` has already made the current vault;
@@ -306,12 +266,13 @@ Bring the repo into policy compliance in one commit:
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 - CI does not compile, lint or test the files built only with cgo on
macOS, since compiling them needs Apple's SDK: macOS, since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker_cgo.go` (the three functions that call `internal/secret/keychainunlocker.go` with `keychainunlocker_test.go`,
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse` `validation_darwin_test.go` and `derivation_index_test.go`, and
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has `internal/macse` (`macse_darwin.go`, `macse_test.go`, the Objective-C
never run on them, so it would likely find more there than the line sources). Lint has never run on them, so it would likely find more
lengths. No macOS test runs in CI. A macOS runner would cover all of it there than the line lengths. No macOS test runs in CI. A macOS runner
(asked on https://git.eeqj.de/sneak/secret/issues/50). 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
@@ -337,6 +298,8 @@ 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,10 +3,8 @@ 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"
) )
@@ -16,11 +14,6 @@ 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
@@ -75,43 +68,3 @@ 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))
}
+16 -58
View File
@@ -2,7 +2,6 @@ package cli_test
import ( import (
"bytes" "bytes"
"os"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
@@ -21,27 +20,16 @@ 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 // the cases share cmd //nolint:paralleltest // t.Setenv forbids parallel subtests
func TestCreateExistingVaultChangesNothing(t *testing.T) { func TestCreateExistingVaultChangesNothing(t *testing.T) {
mnemonic := testMnemonicBuffer(t) t.Setenv(secret.EnvMnemonic, testMnemonic)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) t.Setenv(secret.EnvUnlockPassphrase, 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 := newCLI(fs) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
cmd := &cobra.Command{} cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd)) require.NoError(t, c.Init(cmd))
@@ -86,7 +74,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(newCLI(fs)) err := tt.run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
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))
@@ -97,11 +85,10 @@ 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.
for _, name := range vaults { t.Setenv(secret.EnvMnemonic, "")
vlt := vault.NewVault(fs, testStateDir, name)
vlt.UnlockPassphrase = passphrase
value, err := vlt.GetSecret("x") for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x")
require.NoError(t, err) require.NoError(t, err)
unchanged := bytes.Equal([]byte("value"), value.Bytes()) unchanged := bytes.Equal([]byte("value"), value.Bytes())
@@ -111,43 +98,19 @@ 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 // the cases share cmd //nolint:paralleltest // t.Setenv forbids parallel subtests
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) { func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
mnemonic := testMnemonicBuffer(t) t.Setenv(secret.EnvMnemonic, testMnemonic)
// 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`.
@@ -155,7 +118,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", mnemonic) _, err := vault.CreateVault(withDefault, testStateDir, "default")
require.NoError(t, err) require.NoError(t, err)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -181,12 +144,7 @@ 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)
// Given no unlock passphrase, both commands prompt for it, which err := tt.run(cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir))
// 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))
+5 -12
View File
@@ -41,9 +41,6 @@ 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)
}, },
} }
@@ -159,8 +156,6 @@ 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 {
@@ -235,8 +230,6 @@ 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)
@@ -315,13 +308,13 @@ func isValidAgeSecretKey(key string) bool {
return err == nil return err == nil
} }
// getSecretValue retrieves the value of a secret with the vault's mnemonic // getSecretValue retrieves the value of a secret using the appropriate
// when it has one, else with the current unlocker // 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 vlt.Mnemonic != nil { if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil, vlt.Mnemonic) return secretObj.GetValue(nil)
} }
unlocker, err := vlt.GetCurrentUnlocker() unlocker, err := vlt.GetCurrentUnlocker()
@@ -329,5 +322,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, nil) return secretObj.GetValue(unlocker)
} }
-5
View File
@@ -76,9 +76,6 @@ 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)
}, },
} }
@@ -170,8 +167,6 @@ 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()
+18 -19
View File
@@ -39,20 +39,16 @@ 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 returns the mnemonic from the environment, cli.Mnemonic, // promptMnemonic reads the mnemonic from the environment or interactively.
// or reads it interactively. The returned cleanup function must be deferred // The returned cleanup function must be deferred by the caller.
// by the caller. func promptMnemonic() (string, func(), error) {
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) { if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
if cli.Mnemonic != nil {
secret.Debug("Using mnemonic from environment variable") secret.Debug("Using mnemonic from environment variable")
return cli.Mnemonic, func() {}, nil return envMnemonic, func() {}, nil
} }
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
@@ -62,23 +58,23 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, 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, nil, fmt.Errorf("failed to read mnemonic: %w", err) return "", 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, mnemonicBuffer.Destroy, nil return mnemonicBuffer.String(), 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 string, mnemonic *memguard.LockedBuffer, stateDir, mnemonicStr string,
) (*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", mnemonic) vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
if err != nil { if err != nil {
secret.Debug("Failed to create default vault", "error", err) secret.Debug("Failed to create default vault", "error", err)
@@ -96,7 +92,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(mnemonic.String(), metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, 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)
@@ -140,13 +136,12 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
} }
// Prompt for mnemonic // Prompt for mnemonic
mnemonic, cleanupMnemonic, err := cli.promptMnemonic() mnemonicStr, cleanupMnemonic, err := 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")
@@ -167,14 +162,18 @@ 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, cleanupPassphrase, err := cli.resolvePassphrase() passphraseBuffer, err := resolvePassphrase()
if err != nil { if err != nil {
return err return err
} }
defer cleanupPassphrase() defer passphraseBuffer.Destroy()
// 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, mnemonic) vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
if err != nil { if err != nil {
return err return err
} }
+16 -24
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, tempDir, secretPath, testMnemonic, runSecret) test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv)
// 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)
@@ -366,15 +366,8 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d") unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
verifyFileExists(t, unlockersDir) verifyFileExists(t, unlockersDir)
// Check current-unlocker file names the unlocker's directory
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify passphrase unlocker was created // Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent)) passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase")
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker metadata // Check unlocker metadata
@@ -389,6 +382,13 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age") encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
verifyFileExists(t, encryptedLTPubKey) verifyFileExists(t, encryptedLTPubKey)
// Check current-unlocker file contains the relative path
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
verifyFileExists(t, currentUnlockerFile)
currentUnlockerContent := readFile(t, currentUnlockerFile)
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
// Verify vault-metadata.json in vault // Verify vault-metadata.json in vault
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json") vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
verifyFileExists(t, vaultMetadata) verifyFileExists(t, vaultMetadata)
@@ -537,8 +537,7 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
verifyFileExists(t, pubKeyFile) verifyFileExists(t, pubKeyFile)
// Verify passphrase unlocker was created // Verify passphrase unlocker was created
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker")) passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase")
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker files // Check unlocker files
@@ -2010,35 +2009,28 @@ 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, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) { func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...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, as separate processes: within one // Run multiple concurrent reads
// 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) {
cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password") output, err := runSecretWithEnv(map[string]string{
cmd.Env = []string{ secret.EnvMnemonic: testMnemonic,
secret.EnvStateDir + "=" + tempDir, }, "get", "database/password")
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(string(output)) == "": case strings.TrimSpace(output) == "":
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id) errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
default: default:
errCh <- nil errCh <- nil
+20 -32
View File
@@ -52,18 +52,15 @@ 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. Each add is given mnemonic, which a forced add // returns their errors.
// 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))
@@ -95,9 +92,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 // times commands against the in-memory lock all tests share //nolint:paralleltest // t.Setenv forbids parallel subtests
func TestConcurrentAddsKeepEveryVersion(t *testing.T) { func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
mnemonic := testMnemonicBuffer(t) t.Setenv(secret.EnvMnemonic, testMnemonic)
const adds = 8 const adds = 8
@@ -110,14 +107,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", mnemonic) _, err := vault.CreateVault(tc.fs, tc.stateDir, "default")
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), mnemonic) { numbered("create", adds)) {
if err == nil { if err == nil {
created++ created++
} else { } else {
@@ -129,15 +126,13 @@ 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), mnemonic) { numbered("force", adds)) {
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)
@@ -181,11 +176,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", testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, testStateDir, "default")
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))
@@ -288,16 +283,14 @@ func setupEveryCommand(
) (string, string) { ) (string, string) {
t.Helper() t.Helper()
mnemonic := testMnemonicBuffer(t) other, err := vault.CreateVault(fs, testStateDir, "other")
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", mnemonic) vlt, err := vault.CreateVault(fs, testStateDir, "work")
require.NoError(t, err) require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false) addTestSecret(t, vlt, []byte("older"), false)
@@ -370,12 +363,7 @@ 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)
@@ -412,8 +400,11 @@ 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 // waitingForLock sees any test's command waiting for the lock //nolint:paralleltest // t.Setenv forbids parallel subtests
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
@@ -477,18 +468,15 @@ 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) {
mnemonic := testMnemonicBuffer(t) t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic) _, err := vault.CreateVault(fs, testStateDir, "default")
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)
@@ -517,11 +505,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", testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, testStateDir, "default")
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))
+12 -13
View File
@@ -6,6 +6,7 @@ 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"
@@ -19,9 +20,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"])
@@ -71,8 +72,6 @@ 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)
@@ -87,9 +86,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)
@@ -112,8 +111,10 @@ 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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
const isSame = "is the same secret on this filesystem" const isSame = "is the same secret on this filesystem"
@@ -149,17 +150,15 @@ 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", testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, stateDir, "other")
require.NoError(t, err) require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t)) vlt, err := vault.CreateVault(fs, stateDir, "default")
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)
@@ -200,12 +199,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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := t.TempDir() stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t)) vlt, err := vault.CreateVault(fs, stateDir, "default")
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)
+16 -35
View File
@@ -33,17 +33,6 @@ 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.
@@ -63,12 +52,13 @@ 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, mnemonic) vlt, err := vault.CreateVault(fs, testStateDir, name)
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)
@@ -173,7 +163,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 // the cases share cmd //nolint:paralleltest // newTwoVaultFs uses t.Setenv
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.
@@ -181,7 +171,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
vaultDir := testStateDir + "/vaults.d/default" vaultDir := testStateDir + "/vaults.d/default"
require.Contains(t, before, vaultDir+"/secrets.d/x/") require.Contains(t, before, vaultDir+"/secrets.d/x/")
require.Contains(t, before, vaultDir+"/current-unlocker") require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
require.Equal(t, "default", before[testStateDir+"/currentvault"]) require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -269,7 +259,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 // the cases share cmd //nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) { func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
@@ -307,17 +297,15 @@ 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 instance is given the mnemonic and // before building a path from it. The mnemonic and the passphrase are set,
// the passphrase, and moves and removals use --force, so that only the name // and moves and removals use --force, so that only the name check stands
// check stands in the way. // in the way.
// //
//nolint:paralleltest // the cases share cmd //nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) { func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
mnemonic := testMnemonicBuffer(t) t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -350,12 +338,7 @@ 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 { func(c *cli.Instance) error { return tt.run(c, name) })
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return tt.run(c, name)
})
}) })
} }
} }
@@ -364,16 +347,14 @@ 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)
@@ -407,9 +388,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,9 +81,6 @@ 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)
@@ -114,9 +111,6 @@ 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") {
@@ -180,9 +174,6 @@ 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)
}, },
} }
@@ -257,9 +248,6 @@ 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)
}, },
} }
@@ -366,8 +354,6 @@ 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
@@ -434,8 +420,6 @@ 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
@@ -464,8 +448,6 @@ 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 {
@@ -651,8 +633,6 @@ 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 {
@@ -1013,10 +993,6 @@ 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 {
+10 -6
View File
@@ -10,6 +10,7 @@ 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"
@@ -70,8 +71,11 @@ 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, testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, testStateDir, testVaultName)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
@@ -201,7 +205,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 // together the subtests lock more than the memlock limit //nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
func TestAddSecretVariousSizes(t *testing.T) { func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -261,7 +265,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 // together the subtests lock more than the memlock limit //nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
func TestImportSecretVariousSizes(t *testing.T) { func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -321,7 +325,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 // together the subtests lock more than the memlock limit //nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
func TestAddSecretBufferGrowth(t *testing.T) { func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth // Test various sizes that should trigger buffer growth
sizes := []int{ sizes := []int{
@@ -388,9 +392,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
+13 -17
View File
@@ -16,6 +16,7 @@ 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"
) )
@@ -229,9 +230,6 @@ 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
@@ -582,19 +580,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
// The new unlocker gets the passphrase from the environment, which also // Check if passphrase is set in environment variable
// unlocks the current passphrase unlocker, else the one entered here var passphraseBuffer *memguard.LockedBuffer
passphraseBuffer := cli.UnlockPassphrase if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
if passphraseBuffer == nil { passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} 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 {
@@ -603,8 +601,8 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID()) cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// CreatePassphraseUnlocker has already made it the current unlocker // Auto-select the newly created unlocker
cmd.Printf("Automatically selected as current unlocker\n") autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
return nil return nil
} }
@@ -615,8 +613,7 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
return errKeychainMacOSOnly return errKeychainMacOSOnly
} }
keychainUnlocker, err := secret.CreateKeychainUnlocker( keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
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)
} }
@@ -646,8 +643,7 @@ func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
return errSecureEnclaveMacOSOnly return errSecureEnclaveMacOSOnly
} }
seUnlocker, err := secret.CreateSecureEnclaveUnlocker( seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
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)
} }
@@ -711,8 +707,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(cli.fs, cli.stateDir, pgpUnlocker, err := secret.CreatePGPUnlocker(
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, gpgKeyID, fingerprint)
if err != nil { if err != nil {
return err return err
} }
+2 -72
View File
@@ -5,88 +5,18 @@ 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 here: this vault's unlockers hold // vault's long-term key, which fails first on every platform but macOS
// no keys. // (https://git.eeqj.de/sneak/secret/issues/88).
// //
//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) {
+1 -10
View File
@@ -122,8 +122,7 @@ 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, with a subkey for encryption, and returns the // without a passphrase there, and returns the key's fingerprint.
// key's fingerprint.
func newTestGPGKey(t *testing.T) string { func newTestGPGKey(t *testing.T) string {
t.Helper() t.Helper()
@@ -152,14 +151,6 @@ 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
} }
+60 -27
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -84,9 +85,6 @@ 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])
}, },
} }
@@ -138,9 +136,6 @@ 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)
}, },
} }
@@ -233,14 +228,28 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return nil return nil
} }
// resolvePassphrase returns the unlock passphrase from the environment, // setMnemonicEnv sets the mnemonic environment variable and returns a
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The // function that restores the previous value
// returned cleanup function must be deferred by the caller. func setMnemonicEnv(mnemonicStr string) func() {
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) { originalMnemonic := os.Getenv(secret.EnvMnemonic)
if cli.UnlockPassphrase != nil { _ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
return func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}
}
// resolvePassphrase returns the unlock passphrase from the environment or
// prompts the user for it with confirmation
func resolvePassphrase() (*memguard.LockedBuffer, error) {
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
return cli.UnlockPassphrase, func() {}, nil return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
} }
secret.Debug("Prompting user for unlock passphrase") secret.Debug("Prompting user for unlock passphrase")
@@ -248,10 +257,10 @@ func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), 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, nil, fmt.Errorf("failed to read passphrase: %w", err) return nil, fmt.Errorf("failed to read passphrase: %w", err)
} }
return passphraseBuffer, passphraseBuffer.Destroy, nil return passphraseBuffer, nil
} }
// CreateVault creates a new vault // CreateVault creates a new vault
@@ -264,13 +273,30 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
} }
defer release() defer release()
mnemonic, cleanupMnemonic, err := cli.promptMnemonic() // Get or prompt for mnemonic
if err != nil { var mnemonicStr string
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
} }
@@ -285,14 +311,18 @@ 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, cleanupPassphrase, err := cli.resolvePassphrase() passphraseBuffer, err := resolvePassphrase()
if err != nil { if err != nil {
return err return err
} }
defer cleanupPassphrase() defer passphraseBuffer.Destroy()
// 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, mnemonic) vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
if err != nil { if err != nil {
return err return err
} }
@@ -382,12 +412,11 @@ func (cli *Instance) vaultImportPreflight(
} }
// Get mnemonic from environment // Get mnemonic from environment
if cli.Mnemonic == nil { mnemonic := os.Getenv(secret.EnvMnemonic)
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))
@@ -510,13 +539,17 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
} }
// Get passphrase from environment variable // Get passphrase from environment variable
passphraseBuffer := cli.UnlockPassphrase passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
if passphraseBuffer == nil { if passphraseStr == "" {
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,9 +54,6 @@ 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])
}, },
} }
@@ -175,8 +172,6 @@ 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 {
+11 -35
View File
@@ -45,17 +45,6 @@ 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) {
@@ -72,8 +61,11 @@ 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", testMnemonicBuffer(t)) vlt, err := vault.CreateVault(fs, testStateDir, "default")
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -91,13 +83,11 @@ 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)
@@ -106,8 +96,6 @@ 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)
@@ -151,9 +139,8 @@ 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)
@@ -174,9 +161,8 @@ 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)
@@ -188,8 +174,6 @@ 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)
@@ -240,9 +224,8 @@ 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)
@@ -269,13 +252,11 @@ 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)
@@ -284,8 +265,6 @@ 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)
@@ -319,12 +298,10 @@ 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)
@@ -384,9 +361,8 @@ 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)
+5 -5
View File
@@ -5,24 +5,24 @@ package macse
import "errors" import "errors"
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo") var errNotSupported = errors.New("secure enclave is only supported on macOS")
// CreateKey fails: the Secure Enclave needs a macOS build with cgo. // CreateKey is not supported on non-darwin platforms.
func CreateKey(_ string) ([]byte, string, error) { func CreateKey(_ string) ([]byte, string, error) {
return nil, "", errNotSupported return nil, "", errNotSupported
} }
// Encrypt fails: the Secure Enclave needs a macOS build with cgo. // Encrypt is not supported on non-darwin platforms.
func Encrypt(_ string, _ []byte) ([]byte, error) { func Encrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported return nil, errNotSupported
} }
// Decrypt fails: the Secure Enclave needs a macOS build with cgo. // Decrypt is not supported on non-darwin platforms.
func Decrypt(_ string, _ []byte) ([]byte, error) { func Decrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported return nil, errNotSupported
} }
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo. // DeleteKey is not supported on non-darwin platforms.
func DeleteKey(_ string) error { func DeleteKey(_ string) error {
return errNotSupported return errNotSupported
} }
+8 -8
View File
@@ -3,7 +3,6 @@ package secret
import ( import (
"errors" "errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -63,12 +62,13 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
return dir, nil return dir, nil
} }
// WriteDir calls write to write the files of the new directory dir into a // WriteDir calls write to write the files of the directory dir. When dir does
// temporary directory from TempDirFor, which is then renamed to dir, so that // not exist yet, write writes them into a temporary directory from TempDirFor,
// neither a failure nor a crash leaves dir half-written; on a failure the // which is then renamed to dir, so that neither a failure nor a crash leaves
// temporary directory is removed, and a failure to remove it is returned // dir half-written; on a failure the temporary directory is removed, and a
// along with the first. A directory cannot be replaced in one rename, so if // failure to remove it is returned along with the first. A directory cannot be
// dir already exists, WriteDir fails without calling write. // renamed over one that has files in it, so when dir already exists, write
// writes into it in place; dir is then never removed.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error { func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir) exists, err := afero.Exists(fs, dir)
if err != nil { if err != nil {
@@ -76,7 +76,7 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
} }
if exists { if exists {
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist) return write(dir)
} }
// Create the directory the finished one is renamed into // Create the directory the finished one is renamed into
+37 -174
View File
@@ -191,22 +191,6 @@ func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
return names return names
} }
// dirFiles returns the contents of the files in dir, by name.
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
t.Helper()
files := map[string]string{}
for _, name := range dirNames(t, fs, dir) {
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
require.NoError(t, err)
files[name] = string(data)
}
return files
}
// writeLongTermKey gives the test vault under stateDir a new long-term key // writeLongTermKey gives the test vault under stateDir a new long-term key
// and returns it. // and returns it.
func writeLongTermKey( func writeLongTermKey(
@@ -235,7 +219,7 @@ func newVaultWithSecret(
) *vault.Vault { ) *vault.Vault {
t.Helper() t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t)) vlt, err := vault.CreateVault(fs, stateDir, name)
require.NoError(t, err) require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value)) buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -345,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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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, testMnemonicBuffer(t)) vlt, err := vault.CreateVault(fs, t.TempDir(), name)
require.NoError(t, err) require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long")) value := memguard.NewBufferFromBytes([]byte("long"))
@@ -377,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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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")
@@ -416,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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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")
@@ -435,7 +419,6 @@ 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()
@@ -544,13 +527,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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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")
@@ -642,11 +625,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) {
t.Parallel()
// No mnemonic, and no current unlocker to get the key from // No mnemonic, and no current unlocker to get the key from
t.Setenv(secret.EnvMnemonic, "")
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil) _, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
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 {
@@ -667,31 +650,30 @@ 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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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()
require.NoError(t, err) require.NoError(t, err)
// The vault has no unlocker yet, so any directory in here is unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
// the new one
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
fs := hookFs{Fs: base, before: func(string, string) error { fs := hookFs{Fs: base, before: func(string, string) error {
for _, name := range dirNames(t, base, unlockersDir) { exists, err := afero.DirExists(base, unlockerDir)
assert.ElementsMatch(t, files, require.NoError(t, err)
dirNames(t, base, filepath.Join(unlockersDir, name)),
if exists {
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir),
"unlocker directory visible before it was complete") "unlocker directory visible before it was complete")
} }
@@ -701,133 +683,14 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase)) passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy() defer passphrase.Destroy()
hooked := vault.NewVault(fs, stateDir, testVaultName) _, err = vault.NewVault(fs, stateDir, testVaultName).
hooked.Mnemonic = vlt.Mnemonic CreatePassphraseUnlocker(passphrase)
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err) require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory())) assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir))
}) })
} }
} }
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
// passphrase unlocker twice, each time with only the current unlocker to open
// the vault. The first replacement fails right after making the new unlocker
// current, so the old one is not removed. The second checks, before every
// change it makes, that the vault opens with the passphrase through its
// current unlocker, which is what a crash at that change would leave; once it
// returns, the vault must have one passphrase unlocker left.
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
require.NoError(t, err)
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Every change after the switch to the new unlocker fails
switched := false
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
if switched {
return errInjected
}
switched = op == opRename && path == currentUnlockerPath
return nil
}}
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.ErrorIs(t, err, errInjected)
unlockers, err := vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
checked := hookFs{Fs: base, before: func(string, string) error {
assertOpens()
return nil
}}
replacing = vault.NewVault(checked, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assertOpens()
unlockers, err = vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 1)
})
}
}
// vaultOpensCheck returns a function that checks that the test vault under
// stateDir opens through its current unlocker, with passphrase, to the
// long-term key ltIdentity. Opening it takes a second, so an unlocker
// directory it has opened through before is not opened again: it must hold
// the same files as then.
func vaultOpensCheck(
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
) func() {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
// The files of each unlocker directory the vault has opened through
opened := map[string]map[string]string{}
return func() {
t.Helper()
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
if before, ok := opened[string(current)]; ok {
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
return
}
opener := vault.NewVault(fs, stateDir, testVaultName)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
opened[string(current)] = files
}
}
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after // TestWriteDirFailureLeavesNothing makes writing a new directory fail after
// a file has been written in it, and checks that neither the directory nor // a file has been written in it, and checks that neither the directory nor
// its temporary directory is left behind; and, when the temporary directory // its temporary directory is left behind; and, when the temporary directory
@@ -873,10 +736,10 @@ func TestWriteDirFailureLeavesNothing(t *testing.T) {
} }
} }
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling // TestWriteDirKeepsExistingDir makes writing into a directory that already
// write, when the directory already exists, and leaves the directory as it // exists fail, and checks that the directory, with what was in it, is still
// was: it never writes into a directory in place. // there: WriteDir writes into it in place and never removes it.
func TestWriteDirRefusesExistingDir(t *testing.T) { func TestWriteDirKeepsExistingDir(t *testing.T) {
t.Parallel() t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
@@ -884,17 +747,17 @@ func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel() t.Parallel()
fs, dir := tfs.open(t) fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "existing") target := filepath.Join(dir, "unlockers.d", "passphrase")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms)) require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs, require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}"))) filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(string) error { err := secret.WriteDir(fs, target, func(got string) error {
t.Error("write called for a directory that exists") assert.Equal(t, target, got)
return nil return errInjected
}) })
require.ErrorIs(t, err, os.ErrExist) require.ErrorIs(t, err, errInjected)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target)) assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
}) })
} }
-6
View File
@@ -16,12 +16,6 @@ const (
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE" EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
// EnvGPGKeyID is the environment variable for providing the GPG key ID // EnvGPGKeyID is the environment variable for providing the GPG key ID
EnvGPGKeyID = "SB_GPG_KEY_ID" EnvGPGKeyID = "SB_GPG_KEY_ID"
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
// new unlocker's directory, keychain item and Secure Enclave key. It runs
// to the nanosecond, so that every new unlocker, even one added right
// after another, gets a directory of its own.
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
) )
// File system permission constants // File system permission constants
+4 -26
View File
@@ -1,6 +1,5 @@
//go:build darwin //go:build darwin && cgo
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
package secret package secret
import ( import (
@@ -9,7 +8,6 @@ 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"
@@ -28,9 +26,7 @@ type realVault struct {
func (v *realVault) GetDirectory() (string, error) { func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil return filepath.Join(v.stateDir, "vaults.d", v.name), nil
} }
func (v *realVault) GetName() string { return v.name } func (v *realVault) GetName() string { return v.name }
//nolint:ireturn // implements VaultInterface
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.
@@ -38,23 +34,10 @@ func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
panic("not used") panic("not used")
} }
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
panic("not used") 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) SetUnlockPassphrase(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) CreatePassphraseUnlocker( func (v *realVault) CreatePassphraseUnlocker(
*memguard.LockedBuffer, *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) { ) (*PassphraseUnlocker, error) {
@@ -85,9 +68,6 @@ func createRealVault(
} }
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) { func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
//nolint:dupword // BIP39 test mnemonic repeats words by design
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"
@@ -103,12 +83,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)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic)) t.Setenv(EnvMnemonic, testMnemonic)
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil) result, err := getLongTermPrivateKey(fs, vault)
require.NoError(t, err) require.NoError(t, err)
defer result.Destroy() defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()), assert.Equal(t, key5.String(), string(result.Bytes()),
+196 -186
View File
@@ -1,22 +1,21 @@
//go:build darwin //go:build darwin && cgo
package secret package secret
import ( import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"time" "time"
"filippo.io/age" "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"
keychain "github.com/keybase/go-keychain"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -26,31 +25,15 @@ const (
// //
//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" KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
// keychainUnlockerType is the metadata type string for keychain unlockers.
keychainUnlockerType = "keychain"
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
macOSFlag = "macos"
) )
// keychainItemNameRegex validates keychain item names // keychainItemNameRegex validates keychain item names
// Allows alphanumeric characters, dots, hyphens, and underscores only // Allows alphanumeric characters, dots, hyphens, and underscores only
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var (
errNotMacOS = errors.New(
"keychain unlockers are only supported on macOS")
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
errUnsupportedCurrentUnlocker = errors.New(
"unsupported current unlocker type for keychain unlocker creation")
)
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data // KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
type KeychainUnlockerMetadata struct { type KeychainUnlockerMetadata struct {
UnlockerMetadata UnlockerMetadata
// Keychain item name // Keychain item name
KeychainItemName string `json:"keychainItemName"` KeychainItemName string `json:"keychainItemName"`
} }
@@ -62,17 +45,6 @@ type KeychainUnlocker struct {
fs afero.Fs fs afero.Fs
} }
// NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for Keychain-based unlockers // GetIdentity implements Unlocker interface for Keychain-based unlockers
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) { func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlocker identity", DebugWith("Getting keychain unlocker identity",
@@ -80,12 +52,44 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
slog.String("unlocker_type", k.GetType()), slog.String("unlocker_type", k.GetType()),
) )
keychainData, err := k.readKeychainData() // Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
return nil, err Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Retrieve data from keychain
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to retrieve data from keychain",
"error", err, "keychain_item", keychainItemName)
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
}
DebugWith("Retrieved data from keychain",
slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Move the keychain data into locked memory; this wipes keychainDataBytes
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 3: Parse keychain data
keychainData, err := decodeKeychainData(keychainDataBuffer)
if err != nil {
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
} }
defer keychainData.AgePrivKeyPassphrase.Destroy() defer keychainData.AgePrivKeyPassphrase.Destroy()
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
// Step 4: Read the encrypted age private key from filesystem // Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age") agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
Debug("Reading encrypted age private key", "path", agePrivKeyPath) Debug("Reading encrypted age private key", "path", agePrivKeyPath)
@@ -106,7 +110,6 @@ 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( agePrivKeyBuffer, err := DecryptWithPassphrase(
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase) encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil { if err != nil {
@@ -143,7 +146,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// GetType implements Unlocker interface // GetType implements Unlocker interface
func (k *KeychainUnlocker) GetType() string { func (k *KeychainUnlocker) GetType() string {
return keychainUnlockerType return "keychain"
} }
// GetMetadata implements Unlocker interface // GetMetadata implements Unlocker interface
@@ -185,9 +188,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 {
err = deleteFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to remove keychain item", Debug("Failed to remove keychain item",
"error", err, "keychain_item", keychainItemName) "error", err, "keychain_item", keychainItemName)
@@ -196,9 +197,7 @@ 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 {
err = RemoveDirAtomic(k.fs, k.Directory)
if 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)
@@ -211,71 +210,34 @@ func (k *KeychainUnlocker) Remove() error {
return nil return nil
} }
// NewKeychainUnlocker creates a new KeychainUnlocker instance
func NewKeychainUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *KeychainUnlocker {
return &KeychainUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetKeychainItemName returns the keychain item name from metadata // GetKeychainItemName returns the keychain item name from metadata
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) { func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
// Load the metadata // Load the metadata
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json") metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath) metadataData, err := afero.ReadFile(k.fs, metadataPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read keychain metadata: %w", err) return "", fmt.Errorf("failed to read keychain metadata: %w", err)
} }
var keychainMetadata KeychainUnlockerMetadata var keychainMetadata KeychainUnlockerMetadata
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
err = json.Unmarshal(metadataData, &keychainMetadata)
if err != nil {
return "", fmt.Errorf("failed to parse keychain metadata: %w", err) return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
} }
return keychainMetadata.KeychainItemName, nil return keychainMetadata.KeychainItemName, nil
} }
// readKeychainData reads and parses the data this unlocker keeps in the
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
// returned AgePrivKeyPassphrase.
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
if err != nil {
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
}
// Step 2: Retrieve data from keychain
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to retrieve data from keychain",
"error", err, "keychain_item", keychainItemName)
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
}
DebugWith("Retrieved data from keychain",
slog.String("unlocker_id", k.GetID()),
slog.Int("data_length", len(keychainDataBytes)),
)
// Move the keychain data into locked memory; this wipes keychainDataBytes
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
defer keychainDataBuffer.Destroy()
// Step 3: Parse keychain data
keychainData, err := decodeKeychainData(keychainDataBuffer)
if err != nil {
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
}
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
return keychainData, nil
}
// generateKeychainUnlockerName generates a unique name for the keychain unlocker // generateKeychainUnlockerName generates a unique name for the keychain unlocker
func generateKeychainUnlockerName(vaultName string) (string, error) { func generateKeychainUnlockerName(vaultName string) (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -283,21 +245,47 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
// Format: secret-<vault>-<hostname>-<time> // Format: secret-<vault>-<hostname>-<date>
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat) enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
} }
// getLongTermPrivateKey derives the long-term private key from mnemonic when // getLongTermPrivateKey retrieves the long-term private key either from
// it is not nil, else gets it through the current unlocker, which is given // environment or current unlocker
// 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, mnemonic, passphrase *memguard.LockedBuffer, fs afero.Fs, vault VaultInterface,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
if mnemonic != nil { // Check if mnemonic is available in environment variable
return deriveLongTermPrivateKey(fs, vault, mnemonic) envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
// Return the private key in a secure buffer
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
} }
// Get the vault to access current unlocker // Get the vault to access current unlocker
@@ -306,10 +294,6 @@ 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 {
@@ -318,7 +302,6 @@ func getLongTermPrivateKey(
// Get encrypted long-term key from current unlocker, handling different types // Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte var encryptedLtPrivKey []byte
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
@@ -349,7 +332,8 @@ func getLongTermPrivateKey(
} }
default: default:
return nil, errUnsupportedCurrentUnlocker return nil, fmt.Errorf(
"unsupported current unlocker type for keychain unlocker creation")
} }
// Decrypt long-term private key using current unlocker // Decrypt long-term private key using current unlocker
@@ -363,52 +347,10 @@ func getLongTermPrivateKey(
return ltPrivKeyBuffer, nil return ltPrivKeyBuffer, nil
} }
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at // CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
// the vault's derivation index, for getLongTermPrivateKey and func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
// getLongTermKeyForSE.
func deriveLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
// Return the private key in a secure buffer
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
// 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
err := checkMacOSAvailable() if err := checkMacOSAvailable(); err != nil {
if err != nil {
return nil, err return nil, err
} }
@@ -450,7 +392,6 @@ func CreateKeychainUnlocker(
// Step 3: Encrypt age private key with the generated passphrase // Step 3: Encrypt age private key with the generated passphrase
// Create a secure buffer for the private key // Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String() agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr)) agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
@@ -461,7 +402,7 @@ func CreateKeychainUnlocker(
} }
// 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, mnemonic, passphrase) ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -488,23 +429,12 @@ func CreateKeychainUnlocker(
} }
defer keychainDataBuffer.Destroy() defer keychainDataBuffer.Destroy()
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
}
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte,
keychainDataBuffer *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Step 7: Prepare enhanced metadata // Step 7: Prepare enhanced metadata
keychainMetadata := KeychainUnlockerMetadata{ keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: keychainUnlockerType, Type: "keychain",
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{keychainUnlockerType, macOSFlag}, Flags: []string{"keychain", "macos"},
}, },
KeychainItemName: keychainItemName, KeychainItemName: keychainItemName,
} }
@@ -517,29 +447,27 @@ func writeKeychainUnlocker(
// Step 8: Write the unlocker's files and store the data in the keychain, // Step 8: Write the unlocker's files and store the data in the keychain,
// the metadata last // the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error { err = WriteDir(fs, unlockerDir, func(dir string) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient)) pubPath := filepath.Join(dir, "pub.txt")
if err != nil { if err := WriteFileAtomic(fs, pubPath, []byte(ageRecipient)); err != nil {
return fmt.Errorf("failed to write age recipient: %w", err) return fmt.Errorf("failed to write age recipient: %w", err)
} }
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey) privPath := filepath.Join(dir, "priv.age")
if err != nil { if err := WriteFileAtomic(fs, privPath, encryptedAgePrivKey); err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err) return fmt.Errorf("failed to write encrypted age private key: %w", err)
} }
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey) ltKeyPath := filepath.Join(dir, "longterm.age")
if err != nil { if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtPrivKeyToAge); err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err) return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
err = storeInKeychain(keychainItemName, keychainDataBuffer) if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err) return fmt.Errorf("failed to store data in keychain: %w", err)
} }
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"), metadataPath := filepath.Join(dir, "unlocker-metadata.json")
metadataBytes) if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err) return fmt.Errorf("failed to write unlocker metadata: %w", err)
} }
@@ -556,24 +484,106 @@ func writeKeychainUnlocker(
}, nil }, nil
} }
// checkMacOSAvailable verifies that we're running on macOS
func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
}
return nil
}
// validateKeychainItemName validates that a keychain item name is safe for // validateKeychainItemName validates that a keychain item name is safe for
// command execution // command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
if itemName == "" { if itemName == "" {
return errKeychainItemNameEmpty return fmt.Errorf("keychain item name cannot be empty")
} }
if !keychainItemNameRegex.MatchString(itemName) { if !keychainItemNameRegex.MatchString(itemName) {
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName) return fmt.Errorf("invalid keychain item name format: %s", itemName)
}
return nil
}
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
} }
return nil return nil
-104
View File
@@ -1,104 +0,0 @@
//go:build darwin && cgo
package secret
import (
"fmt"
"github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
)
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
}
return nil
}
-30
View File
@@ -1,30 +0,0 @@
//go:build darwin && !cgo
package secret
import (
"errors"
"github.com/awnumar/memguard"
)
// In a macOS build without cgo, these take the place of the functions in
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
var errKeychainNotSupported = errors.New(
"keychain unlockers need a macOS build with cgo")
// storeInKeychain fails: the keychain needs a macOS build with cgo.
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
return errKeychainNotSupported
}
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
func retrieveFromKeychain(_ string) ([]byte, error) {
return nil, errKeychainNotSupported
}
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
func deleteFromKeychain(_ string) error {
return errKeychainNotSupported
}
+9 -4
View File
@@ -1,4 +1,4 @@
//go:build !darwin //go:build !darwin || !cgo
package secret package secret
@@ -76,8 +76,13 @@ func (k *KeychainUnlocker) Remove() error {
} }
// CreateKeychainUnlocker returns an error on non-Darwin platforms // CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker( func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer, return nil, errKeychainNotSupported
) (*KeychainUnlocker, error) { }
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(
_ afero.Fs, _ VaultInterface,
) (*memguard.LockedBuffer, error) {
return nil, errKeychainNotSupported return nil, errKeychainNotSupported
} }
+19 -34
View File
@@ -19,17 +19,6 @@ 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,
@@ -174,7 +163,7 @@ func newTestPassphraseUnlocker(
return unlocker, ageIdentity, unlockerDir return unlocker, ageIdentity, unlockerDir
} }
//nolint:paralleltest // subtests share real-FS state, order matters //nolint:paralleltest // subtests share real-FS state and t.Setenv, 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" {
@@ -206,42 +195,38 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey) writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
}) })
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase)) // Set test environment variable (cleaned up automatically)
defer passphrase.Destroy() t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
unlocker.Passphrase = passphrase // Test getting identity from environment variable
t.Run("GetIdentityFromEnv", func(t *testing.T) {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity from env: %v", err)
}
// Test getting identity with the passphrase the unlocker was given, // Verify the identity matches what we expect
// twice: using it must leave it intact for the next use expectedPubKey := ageIdentity.Recipient().String()
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)
}
// Verify the identity matches what we expect actualPubKey := identity.Recipient().String()
expectedPubKey := ageIdentity.Recipient().String() if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
actualPubKey := identity.Recipient().String() expectedPubKey, actualPubKey)
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
} }
}) })
unlocker.Passphrase = nil // Unset the environment variable to test interactive prompt
_ = 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("GetIdentityWithoutPassphrase", func(t *testing.T) { t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
// This should fail since we're not in an interactive terminal // This should fail since we're not in an interactive terminal
_, err := unlocker.GetIdentity() _, err := unlocker.GetIdentity()
if err == nil { if err == nil {
t.Errorf("Should have failed to get identity without a passphrase") t.Errorf("Should have failed to get identity without passphrase env var")
} }
}) })
+18 -8
View File
@@ -3,6 +3,7 @@ package secret
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
@@ -134,19 +135,28 @@ func (p *PassphraseUnlocker) Remove() error {
return nil return nil
} }
// getPassphrase returns a copy of p.Passphrase, or else asks the user for // getPassphrase retrieves the passphrase from memory, environment, or
// the passphrase. The caller must destroy the returned buffer. // user input. Returns a LockedBuffer for secure memory handling
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())
// Not NewBufferFromBytes, which would wipe p.Passphrase // Return a copy of the passphrase buffer
passphrase := memguard.NewBuffer(p.Passphrase.Size()) return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, nil
} }
Debug("No passphrase in memory, prompting user") Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return secureBuffer, nil
}
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase // Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ") secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil { if err != nil {
+3 -5
View File
@@ -268,6 +268,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
// 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
@@ -327,10 +328,8 @@ func testCreatePGPUnlocker(
}) })
defer timer.Stop() defer timer.Stop()
mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure // Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic) vlt, err := vault.CreateVault(fs, stateDir, vaultName)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -380,8 +379,7 @@ func testCreatePGPUnlocker(
} }
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc) // Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker( pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint)
fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err) t.Fatalf("Failed to create PGP unlock key: %v", err)
} }
+13 -20
View File
@@ -209,20 +209,21 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
} }
// generatePGPUnlockerName generates a unique name for the PGP unlocker // generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and time // based on hostname and date
func generatePGPUnlockerName() (string, error) { func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat) // Format: hostname-pgp-YYYY-MM-DD
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
} }
// pgpUnlockerDir returns the current vault and the directory in it for a // pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the time. // new PGP unlocker, named after the host and the day.
// //
//nolint:ireturn // the vault is only available behind VaultInterface //nolint:ireturn // the vault is only available behind VaultInterface
func pgpUnlockerDir( func pgpUnlockerDir(
@@ -234,7 +235,7 @@ func pgpUnlockerDir(
return nil, "", fmt.Errorf("failed to get current vault: %w", err) return nil, "", fmt.Errorf("failed to get current vault: %w", err)
} }
// Generate the unlocker name based on hostname and time // Generate the unlocker name based on hostname and date
unlockerName, err := generatePGPUnlockerName() unlockerName, err := generatePGPUnlockerName()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err) return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
@@ -253,12 +254,9 @@ 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. The long-term key comes from mnemonic when it // leaves no partial unlocker.
// 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 {
@@ -270,10 +268,6 @@ 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 {
@@ -283,7 +277,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(
vault, ageIdentity, gpgKeyID) fs, vault, ageIdentity, gpgKeyID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -322,15 +316,14 @@ 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(
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string, fs afero.Fs, vault VaultInterface,
ageIdentity *age.X25519Identity, gpgKeyID string,
) ([]byte, []byte, error) { ) ([]byte, []byte, error) {
// From the mnemonic or the current unlocker, as for a passphrase unlocker // Get or derive the long-term private key
ltIdentity, err := vault.GetOrDeriveLongTermKey() ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err) return nil, nil, err
} }
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient( encryptedLtPrivKey, err := EncryptToRecipient(
+6 -43
View File
@@ -7,7 +7,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/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -41,14 +40,15 @@ 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 there is no mnemonic and no current unlocker. // fails because on macOS there is no mnemonic and no current unlocker, and
// // on every other platform it always fails
//nolint:paralleltest // installFakeGPG uses t.Setenv // (https://git.eeqj.de/sneak/secret/issues/88).
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, nil) vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
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,47 +58,10 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
}} }}
_, err = secret.CreatePGPUnlocker( _, err = secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil) fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint)
require.Error(t, err) require.Error(t, err)
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d"))) assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
} }
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
// the other, so on the same host and day, and checks that the second gets a
// directory of its own and leaves the first one's files as they were.
// CreatePGPUnlocker does not check whether the GPG key already has an
// unlocker, so the test key serves for both.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
installFakeGPG(t)
original := secret.GPGEncryptFunc
t.Cleanup(func() { secret.GPGEncryptFunc = original })
// Stands in for gpg, which the test does not have: "encrypts" by copying
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
return []byte(data.String()), nil
}
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
require.NoError(t, err)
first, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
firstFiles := dirFiles(t, fs, first.GetDirectory())
second, err := secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
require.NoError(t, err)
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
}
+11 -18
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -34,12 +35,6 @@ 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)
} }
@@ -81,12 +76,9 @@ func NewSecret(vault VaultInterface, name string) *Secret {
} }
} }
// GetValue retrieves and decrypts the current version's value, with the // GetValue retrieves and decrypts the current version's value using the
// vault's long-term key derived from mnemonic when it is not nil, else // provided unlocker
// obtained through unlocker func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
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()),
@@ -121,8 +113,9 @@ func (s *Secret) GetValue(
// Create version object // Create version object
version := NewVersion(s.vault, s.Name, currentVersion) version := NewVersion(s.vault, s.Name, currentVersion)
if mnemonic != nil { // Check for SB_SECRET_MNEMONIC environment variable for direct decryption
return s.getValueViaMnemonic(version, mnemonic.String()) if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
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)
@@ -216,11 +209,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 and decrypts the version value with it. // mnemonic in the environment and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic( func (s *Secret) getValueViaMnemonic(
version *Version, mnemonic string, version *Version, envMnemonic string,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic for direct long-term key derivation", Debug("Using mnemonic from environment 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
@@ -257,7 +250,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(mnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 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)
+23 -54
View File
@@ -2,7 +2,6 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
@@ -23,7 +22,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("mock vault has no mnemonic") errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
errNotImplementedInMock = errors.New("not implemented in mock") errNotImplementedInMock = errors.New("not implemented in mock")
) )
@@ -33,7 +32,6 @@ 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) {
@@ -63,11 +61,12 @@ 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
if m.mnemonic == nil { mnemonic := os.Getenv(EnvMnemonic)
if mnemonic == "" {
return errMnemonicNotSet return errMnemonicNotSet
} }
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
if err != nil { if err != nil {
return err return err
} }
@@ -108,16 +107,6 @@ 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) {
@@ -245,13 +234,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
} }
} }
//nolint:paralleltest // subtests share one vault, order matters //nolint:paralleltest // uses t.Setenv (process-global environment)
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()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue)) // Set test mnemonic for direct encryption/decryption
defer mnemonic.Destroy() t.Setenv(EnvMnemonic, testMnemonicValue)
// 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"
@@ -265,7 +254,6 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
fs: fs, fs: fs,
directory: vaultDir, directory: vaultDir,
derivationIndex: 0, derivationIndex: 0,
mnemonic: mnemonic,
} }
// Test data // Test data
@@ -322,45 +310,26 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
}) })
} }
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
// GetValue, given the mnemonic, derives the long-term key at the derivation // This test demonstrates the bug where GetValue uses hardcoded index 0
// index in the vault's metadata. At index 0 it could not decrypt the secret, // instead of the vault's actual derivation index when using environment mnemonic
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() // Set up test mnemonic
vaultDir := "/test-config/vaults.d/test-vault" t.Setenv(EnvMnemonic, testMnemonicValue)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue)) // Create temporary directory for vaults
defer mnemonic.Destroy() fs := afero.NewOsFs()
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)
secretName, secretValue := "x", "value" defer func() {
_ = fs.RemoveAll(tempDir)
}()
err = vlt.AddSecret(secretName, stateDir := filepath.Join(tempDir, ".secret")
memguard.NewBufferFromBytes([]byte(secretValue)), false) require.NoError(t, fs.MkdirAll(stateDir, 0o700))
require.NoError(t, err)
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic) // This test is now in the integration test file where it can use real vaults
require.NoError(t, err) // The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
t.Log("This test demonstrates the bug in the integration test file")
defer value.Destroy()
require.Equal(t, secretValue, value.String())
} }
+56 -17
View File
@@ -4,14 +4,17 @@ 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"
"git.eeqj.de/sneak/secret/internal/macse" "git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -27,6 +30,8 @@ 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
@@ -200,26 +205,33 @@ func generateSEKeyLabel(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat) enrollmentDate := time.Now().UTC().Format("2006-01-02")
return fmt.Sprintf( return fmt.Sprintf(
"%s.%s-%s-%s", "%s.%s-%s-%s",
seKeyLabelPrefix, seKeyLabelPrefix,
vaultName, vaultName,
hostname, hostname,
enrollmentTime, enrollmentDate,
), 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() err := checkMacOSAvailable()
if err != nil { if err != nil {
@@ -248,7 +260,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, mnemonic, passphrase) ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
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",
@@ -279,7 +291,7 @@ func CreateSecureEnclaveUnlocker(
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType, Type: seUnlockerType,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, macOSFlag}, Flags: []string{seUnlockerType, "macos"},
}, },
SEKeyLabel: seKeyLabel, SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash, SEKeyHash: seKeyHash,
@@ -328,16 +340,47 @@ func writeSEUnlockerFiles(
return nil return nil
} }
// getLongTermKeyForSE retrieves the vault's long-term private key, derived // getLongTermKeyForSE retrieves the vault's long-term private key
// from mnemonic when it is not nil, else through the current unlocker, which // either from the mnemonic env var or by unlocking via the current unlocker.
// is given passphrase when it is a passphrase 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) {
if mnemonic != nil { envMnemonic := os.Getenv(EnvMnemonic)
return deriveLongTermPrivateKey(fs, vault, mnemonic) if envMnemonic != "" {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
envMnemonic,
metadata.DerivationIndex,
)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w",
err,
)
}
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
} }
currentUnlocker, err := vault.GetCurrentUnlocker() currentUnlocker, err := vault.GetCurrentUnlocker()
@@ -345,10 +388,6 @@ 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(
-2
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -81,7 +80,6 @@ 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", nil, nil) unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
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)
+121 -30
View File
@@ -1,6 +1,5 @@
//go:build darwin //go:build darwin && cgo
//nolint:testpackage // white-box test of unexported validateKeychainItemName
package secret package secret
import ( import (
@@ -8,46 +7,138 @@ import (
) )
func TestValidateKeychainItemName(t *testing.T) { func TestValidateKeychainItemName(t *testing.T) {
t.Parallel()
tests := []struct { tests := []struct {
name string name string
itemName string itemName string
wantErr bool wantErr bool
}{ }{
// Valid cases // Valid cases
{name: "valid simple name", itemName: "my-secret-key", wantErr: false}, {
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false}, name: "valid simple name",
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false}, itemName: "my-secret-key",
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false}, wantErr: false,
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false}, },
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false}, {
name: "valid name with dots",
itemName: "com.example.app.key",
wantErr: false,
},
{
name: "valid name with underscores",
itemName: "my_secret_key_123",
wantErr: false,
},
{
name: "valid alphanumeric",
itemName: "Secret123Key",
wantErr: false,
},
{
name: "valid with hyphen at start",
itemName: "-my-key",
wantErr: false,
},
{
name: "valid with dot at start",
itemName: ".hidden-key",
wantErr: false,
},
// Invalid cases // Invalid cases
{name: "empty item name", itemName: "", wantErr: true}, {
{name: "item name with spaces", itemName: "my secret key", wantErr: true}, name: "empty item name",
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true}, itemName: "",
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true}, wantErr: true,
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true}, },
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true}, {
{name: "item name with quotes", itemName: "key\"name", wantErr: true}, name: "item name with spaces",
{name: "item name with single quotes", itemName: "key'name", wantErr: true}, itemName: "my secret key",
{name: "item name with backslash", itemName: "key\\name", wantErr: true}, wantErr: true,
{name: "item name with newline", itemName: "key\nname", wantErr: true}, },
{name: "item name with carriage return", itemName: "key\rname", wantErr: true}, {
{name: "item name with ampersand", itemName: "key&echo test", wantErr: true}, name: "item name with semicolon",
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true}, itemName: "key;rm -rf /",
{name: "item name with null byte", itemName: "key\x00name", wantErr: true}, wantErr: true,
{name: "item name with parentheses", itemName: "key(test)", wantErr: true}, },
{name: "item name with brackets", itemName: "key[test]", wantErr: true}, {
{name: "item name with asterisk", itemName: "key*", wantErr: true}, name: "item name with pipe",
{name: "item name with question mark", itemName: "key?", wantErr: true}, itemName: "key|cat /etc/passwd",
wantErr: true,
},
{
name: "item name with backticks",
itemName: "key`whoami`",
wantErr: true,
},
{
name: "item name with dollar sign",
itemName: "key$(whoami)",
wantErr: true,
},
{
name: "item name with quotes",
itemName: "key\"name",
wantErr: true,
},
{
name: "item name with single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKeychainItemName(tt.itemName) err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
-8
View File
@@ -87,14 +87,6 @@ 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) {
+20 -25
View File
@@ -8,6 +8,7 @@ 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"
@@ -40,49 +41,46 @@ 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)
}) })
} }
@@ -98,8 +96,7 @@ 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)
} }
@@ -144,10 +141,9 @@ 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 writes the public key derived from // Create a test vault - CreateVault now handles public key when
// the mnemonic // mnemonic is in env
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)
} }
@@ -220,10 +216,9 @@ 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 writes the public key derived from // Create a test vault - CreateVault now handles public key when
// the mnemonic // mnemonic is in env
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)
} }
@@ -324,7 +319,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, testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, stateDir, name)
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)
} }
@@ -340,7 +335,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, testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, stateDir, name)
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)
@@ -361,7 +356,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, testMnemonicBuffer(t)) _, err := vault.CreateVault(fs, stateDir, name)
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)
} }
@@ -409,14 +404,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 writes the public key derived from // Create two vaults - CreateVault now handles public key when mnemonic
// the mnemonic // is in env
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t)) vault1, err := vault.CreateVault(fs, stateDir, "vault1")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault1: %v", err) t.Fatalf("Failed to create vault1: %v", err)
} }
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t)) vault2, err := vault.CreateVault(fs, stateDir, "vault2")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault2: %v", err) t.Fatalf("Failed to create vault2: %v", err)
} }
+10 -9
View File
@@ -44,12 +44,15 @@ var errUnexpectedValue = errors.New("unexpected value")
// TestVersionIntegrationWorkflow tests the complete version workflow // TestVersionIntegrationWorkflow tests the complete version workflow
// //
//nolint:paralleltest // the subtests are steps that build on each other //nolint:paralleltest // t.Setenv forbids parallel subtests
func TestVersionIntegrationWorkflow(t *testing.T) { func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault without a long-term key, which is set up below // Set mnemonic for testing
vault, err := CreateVault(fs, testStateDir, "test", nil) t.Setenv(secret.EnvMnemonic, testMnemonic)
// 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
@@ -348,9 +351,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
@@ -363,8 +366,6 @@ 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)
@@ -402,9 +403,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
+16 -23
View File
@@ -3,6 +3,7 @@ package vault
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
@@ -10,7 +11,6 @@ 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,18 +155,19 @@ 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) {
if mnemonicBuffer == nil { // Check if mnemonic is available in environment
secret.Debug("No mnemonic given, vault created without long-term key", mnemonic := os.Getenv(secret.EnvMnemonic)
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
} }
mnemonic := mnemonicBuffer.String() secret.Debug("Mnemonic found in environment, deriving long-term key",
"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)
@@ -207,17 +208,12 @@ func processMnemonicForVault(
return derivationIndex, publicKeyHash, familyHash, nil return derivationIndex, publicKeyHash, familyHash, nil
} }
// CreateVault creates a new vault and selects it as the current vault. When // CreateVault creates a new vault and selects it as the current vault. It
// mnemonic is not nil, the vault's long-term key is derived from it, and the // refuses a vault that already exists before writing anything: creating it
// returned vault has it as its Mnemonic; when it is nil, the vault has no // again would replace its keys, and its secrets could no longer be
// long-term key until one is imported. It refuses a vault that already // decrypted. The commands that call it hold the state directory lock, so no
// exists before writing anything: creating it again would replace its keys, // other command can create the vault between the check and the writes.
// and its secrets could no longer be decrypted. The commands that call it func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// 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)
@@ -267,7 +263,7 @@ func CreateVault(
// Process mnemonic if available // Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault( derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name, mnemonic) fs, stateDir, vaultDir, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -296,10 +292,7 @@ func CreateVault(
// Create and return the vault // Create and return the vault
secret.Debug("Successfully created vault", "name", name) secret.Debug("Successfully created vault", "name", name)
vlt := NewVault(fs, stateDir, name) return NewVault(fs, stateDir, name), nil
vlt.Mnemonic = mnemonic
return vlt, nil
} }
// SelectVault selects the given vault as the current vault // SelectVault selects the given vault as the current vault
+10 -6
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)
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t)) t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
_, 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,15 +321,19 @@ 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)
_, err = vault.CreateVault(fs, tempDir, "work", nil) t.Setenv("SB_SECRET_MNEMONIC", "")
_, 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: get the next available derivation // Simulate the vault import process
// index for this mnemonic t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
// 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)
+14 -13
View File
@@ -3,6 +3,7 @@ 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"
@@ -12,13 +13,15 @@ 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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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
@@ -38,8 +41,6 @@ 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)
@@ -52,12 +53,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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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")
@@ -67,13 +68,15 @@ 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.Parallel() t.Setenv(secret.EnvMnemonic, testMnemonic)
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{
@@ -84,8 +87,6 @@ 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")
+19 -14
View File
@@ -41,6 +41,14 @@ 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"
@@ -65,8 +73,11 @@ func addTestSecretToVault(
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault { func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper() t.Helper()
// Create vault without a long-term key, which is set up below // Set mnemonic for testing
vault, err := CreateVault(fs, testStateDir, "test", nil) t.Setenv(secret.EnvMnemonic, envTestMnemonic)
// 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
@@ -87,9 +98,8 @@ 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
@@ -127,9 +137,8 @@ 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
@@ -165,9 +174,8 @@ 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
@@ -212,9 +220,8 @@ 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
@@ -296,9 +303,8 @@ 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
@@ -313,9 +319,8 @@ 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
+11 -66
View File
@@ -2,10 +2,8 @@ package vault
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -72,9 +70,7 @@ 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)
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) unlocker = 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)
@@ -105,7 +101,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// resolveUnlockerDirectory reads the current-unlocker file to get the // resolveUnlockerDirectory reads the current-unlocker file to get the
// unlocker directory path // unlocker directory path
// The file contains just the name of the unlocker's directory in unlockers.d // The file contains just the unlocker name (e.g., "passphrase")
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath) secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -343,10 +339,7 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil return nil
} }
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a // CreatePassphraseUnlocker creates a new passphrase-protected unlocker
// directory of its own, makes it the current unlocker, and only then removes
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
// point leaves a complete current unlocker, the old one or the new.
// The passphrase must be provided as a LockedBuffer for security // The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker( func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
@@ -358,23 +351,13 @@ func (v *Vault) CreatePassphraseUnlocker(
// We need to get the long-term key (either from memory if unlocked, or // We need to get the long-term key (either from memory if unlocked, or
// derive it). Getting it before anything is written means failing to // derive it). Getting it before anything is written means failing to
// get it changes nothing. // get it changes nothing, even when replacing the current unlocker.
ltIdentity, err := v.GetOrDeriveLongTermKey() ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err) return nil, fmt.Errorf("failed to get long-term key: %w", err)
} }
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
// The passphrase unlockers the new one replaces
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
if err != nil {
return nil, err
}
createdAt := time.Now()
unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
createdAt.UTC().Format(secret.UnlockerTimeFormat))
// Generate new age keypair for unlocker // Generate new age keypair for unlocker
unlockerIdentity, err := age.GenerateX25519Identity() unlockerIdentity, err := age.GenerateX25519Identity()
@@ -394,7 +377,7 @@ func (v *Vault) CreatePassphraseUnlocker(
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: unlockerTypePassphrase, Type: unlockerTypePassphrase,
CreatedAt: createdAt, CreatedAt: time.Now(),
Flags: []string{}, Flags: []string{},
} }
@@ -412,54 +395,16 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err return nil, err
} }
// Select the new unlocker by its directory, not by its ID: an old // Create the unlocker instance
// passphrase unlocker created in the same minute has the same ID. unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath, // Select this unlocker as current
[]byte(filepath.Base(unlockerDir))) err = v.SelectUnlocker(unlocker.GetID())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err) return nil, fmt.Errorf("failed to select new unlocker: %w", err)
} }
for _, oldDir := range oldDirs { return unlocker, nil
err = secret.RemoveDirAtomic(v.fs, oldDir)
if err != nil {
return nil, fmt.Errorf(
"created and selected the new passphrase unlocker: %w", err)
}
}
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
}
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
// same warning.
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
files, err := afero.ReadDir(v.fs, unlockersDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
}
var dirs []string
for _, file := range files {
if !file.IsDir() {
continue
}
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok && metadata.Type == unlockerTypePassphrase {
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
}
}
return dirs, nil
} }
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in // readUnlockerMetadata reads and parses the unlocker-metadata.json file in
+7 -25
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,13 +18,6 @@ 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
@@ -63,18 +56,6 @@ 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) {
@@ -85,8 +66,9 @@ 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)
if v.Mnemonic != nil { // Try to derive from environment mnemonic first
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String()) if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
} }
// No mnemonic available, try to use current unlocker // No mnemonic available, try to use current unlocker
@@ -199,9 +181,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(
mnemonic string, envMnemonic string,
) (*age.X25519Identity, error) { ) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic for long-term key derivation", secret.Debug("Using mnemonic from environment 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
@@ -217,7 +199,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(mnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 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)
+10 -19
View File
@@ -27,19 +27,12 @@ const (
testPassphrase = "test-passphrase" testPassphrase = "test-passphrase"
) )
// testMnemonicBuffer returns testMnemonic in a locked buffer that is //nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
// 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
}
//nolint:paralleltest // order-dependent subtests forbid parallel
func TestVaultOperations(t *testing.T) { func TestVaultOperations(t *testing.T) {
// Test environment will be cleaned up automatically by t.Setenv
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Use in-memory filesystem // Use in-memory filesystem
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
@@ -71,8 +64,7 @@ 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)
} }
@@ -229,8 +221,6 @@ 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 {
@@ -291,14 +281,15 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
} }
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) { func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
t.Parallel() // Set test environment variables
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 -4
View File
@@ -5,10 +5,9 @@
# stage of Dockerfile.lint, rebuilt on every run as script/lint does. # 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 # 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's # leaves out the files built only with cgo on macOS: the keychain unlocker
# calls into the keychain (keychainunlocker_cgo.go, and # (keychainunlocker.go and its tests) and the Secure Enclave bindings
# keychainunlocker_test.go) and the Secure Enclave bindings (internal/macse). # (internal/macse). Nothing on Linux checks those.
# Nothing on Linux checks those.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"