5 Commits
Author SHA1 Message Date
sneak abde576300 Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 4s
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,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
2026-10-04 15:15:59 +00:00
clawbot 7e4e0f7806 Give every new unlocker a directory of its own (closes #71)
check / check (push) Failing after 1s
A passphrase unlocker added to a vault that had one, and a PGP, keychain
or Secure Enclave unlocker added on the same day as another of its type,
were written into the existing unlocker's directory file by file, so a
crash part-way left a current unlocker whose files did not belong
together.

Unlocker directories, keychain items and Secure Enclave keys are now
named with the time to the nanosecond, and secret.WriteDir refuses a
directory that exists. Adding a passphrase unlocker writes the new one,
points current-unlocker at it, and only then removes the vault's other
passphrase unlockers.

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

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

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

Model: opus-5-5
2026-10-04 14:42:01 +02:00
51 changed files with 1588 additions and 889 deletions
+66 -2
View File
@@ -10,14 +10,20 @@ run:
linters:
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:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- 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:
lll:
line-length: 88
@@ -28,6 +34,64 @@ linters:
max-complexity: 15
dupl:
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:
max-issues-per-linter: 0
+26 -6
View File
@@ -197,6 +197,9 @@ Creates a new unlocker of the specified type:
**Options:**
- `--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` ⚠️ 🛑
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
@@ -243,8 +246,8 @@ Decrypts data using an Age key stored as a secret.
├── vaults.d/
│ ├── default/
│ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlocker
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
│ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/
@@ -260,7 +263,7 @@ Decrypts data using an Age key stored as a secret.
│ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker -> ../unlockers.d/passphrase
│ │ └── current-unlocker # Current unlocker's directory name
│ └── work/
│ ├── unlockers.d/
│ ├── secrets.d/
@@ -318,6 +321,18 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
- `SB_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
### Encryption
@@ -457,6 +472,10 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
- **macOS**: Full support including Keychain and Secure Enclave integration
- **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
### Threat Model
@@ -521,9 +540,10 @@ them. We provide:
call, also on an unchanged tree
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on
the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
build never compiles; cgo is off, so the keychain unlocker
(`internal/secret/keychainunlocker.go` and its tests) and the Secure
Enclave bindings (`internal/macse`) are not checked
build never compiles; cgo is off, so the keychain unlocker's calls into
the keychain (`internal/secret/keychainunlocker_cgo.go`, and
`keychainunlocker_test.go`) and the Secure Enclave bindings
(`internal/macse`) are not checked
- `script/fmt` — format all Go code (writes)
- `script/fmt-check` — check formatting without writing
- `script/check` — run `script/test`, `script/lint`,
+68 -31
View File
@@ -28,19 +28,67 @@ Bring the repo into policy compliance in one commit:
- 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
(`GOOS=darwin`), with cgo off
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it,
and the `Dockerfile` lint stage runs its commands, so `script/cibuild`
does too. Before, CI on Linux never compiled the files built only for
macOS. Compiling cgo code for macOS needs Apple's SDK headers, and both
`internal/macse` and `github.com/keybase/go-keychain`, which
`keychainunlocker.go` uses, are cgo on macOS. So `keychainunlocker.go`
and its tests are now built only with cgo on macOS, like
`macse_darwin.go`, and the keychain and `macse` stubs serve a macOS
build without cgo, which before did not compile. `checkMacOSAvailable`
moved to `seunlocker_darwin.go`. The check covers the Secure Enclave
unlocker and the macOS-only tests `seunlocker_test.go` and
`pgpunlock_test.go`, whose lint findings are fixed; lines over 88
columns in the macOS files it cannot check are wrapped.
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
Before, CI on Linux never compiled the files built only for macOS. Compiling
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions
that call `go-keychain` moved from `keychainunlocker.go` to
`keychainunlocker_cgo.go`, built only with cgo on macOS like
`macse_darwin.go`. A macOS build without cgo, which before did not compile,
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
say the keychain or Secure Enclave needs a macOS build with cgo. The check
covers the rest of the keychain unlocker, the Secure Enclave unlocker and
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of
`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,
`.`, `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
@@ -66,9 +114,7 @@ Bring the repo into policy compliance in one commit:
and encrypt everything before writing anything. All four unlocker
types write their files through `secret.WriteDir`: a new unlocker is
built in a temporary directory, renamed into place when complete and
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).
removed on a failure.
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
skip, with the warning `unlocker list` gives, an unlocker directory
whose metadata file cannot be checked for, read or parsed, instead of
@@ -164,12 +210,6 @@ Bring the repo into policy compliance in one commit:
into place, and removals rename out of the way first, so a version
or secret is never half-added and never half-removed. An
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
but before the unlocker is written, a vault with no unlocker,
which `vault create` has already made the current vault;
@@ -266,13 +306,12 @@ Bring the repo into policy compliance in one commit:
in-code FIXME removed for godox).
- CI does not compile, lint or test the files built only with cgo on
macOS, since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker.go` with `keychainunlocker_test.go`,
`validation_darwin_test.go` and `derivation_index_test.go`, and
`internal/macse` (`macse_darwin.go`, `macse_test.go`, the Objective-C
sources). Lint has never run on them, so it would likely find more
there than the line lengths. No macOS test runs in CI. A macOS runner
would cover all of it (asked on
https://git.eeqj.de/sneak/secret/issues/50).
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
never run on them, so it would likely find more there than the line
lengths. No macOS test runs in CI. A macOS runner would cover all of it
(asked on https://git.eeqj.de/sneak/secret/issues/50).
- Merge secure-enclave-unlocker to main once review is done.
- 1.0 critical security blockers (from repo TODO.md):
- Command injection: GPG key IDs passed unescaped to exec.Command
@@ -298,8 +337,6 @@ Bring the repo into policy compliance in one commit:
suggestions.
- Validate GPG key existence before creating PGP unlock keys.
- Split oversized CLI functions.
- Document env var security (SB_UNLOCK_PASSPHRASE,
SB_SECRET_MNEMONIC); clear after use.
- mlock/munlock for sensitive allocations.
- Cleanups: read statedir from environment or default instead of
passing it around.
+47
View File
@@ -3,8 +3,10 @@ package cli
import (
"fmt"
"os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@@ -14,6 +16,11 @@ type Instance struct {
fs afero.Fs
stateDir string
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
@@ -68,3 +75,43 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string {
return cli.stateDir
}
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
// either calls it once, before anything else, and passes the buffers on
// from there: each variable is unset as soon as it is read, so that the
// processes this one starts, gpg among them, do not inherit it, and a
// second read would find nothing. The returned function destroys both
// buffers.
func (cli *Instance) readSecretEnv() func() {
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
return func() {
if mnemonic != nil {
mnemonic.Destroy()
}
if passphrase != nil {
passphrase.Destroy()
}
}
}
// readAndUnsetEnv returns the value of the environment variable name in a
// locked buffer, or nil when it is unset or empty, and unsets the variable.
// Unsetting does not erase the value: it stays in this process's memory,
// and in /proc/<pid>/environ, which shows the environment the process
// started with. The caller must destroy the returned buffer.
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
value := os.Getenv(name)
_ = os.Unsetenv(name)
if value == "" {
return nil
}
return memguard.NewBufferFromBytes([]byte(value))
}
+58 -16
View File
@@ -2,6 +2,7 @@ package cli_test
import (
"bytes"
"os"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
@@ -20,16 +21,27 @@ import (
// decrypted any more. Each must refuse, change nothing, and leave every
// vault's secret readable through its passphrase unlocker.
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
//nolint:paralleltest // the cases share cmd
func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
// newCLI returns an instance on fs given the mnemonic and the unlock
// passphrase, as from the environment
newCLI := func(fs afero.Fs) *cli.Instance {
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return c
}
// `secret init`, `secret vault create work`, `secret vault select
// default`, and the secret "x" in each vault. "work" is then not the
// current vault, which creating it again must not change.
fs := afero.NewMemMapFs()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c := newCLI(fs)
cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd))
@@ -74,7 +86,7 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before)
err := tt.run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
err := tt.run(newCLI(fs))
require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs))
@@ -85,10 +97,11 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
// reading each vault's secret once from it shows that it still decrypts
// after each case. Without the mnemonic, reading a secret goes through
// the vault's passphrase unlocker, which is slow.
t.Setenv(secret.EnvMnemonic, "")
for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x")
vlt := vault.NewVault(fs, testStateDir, name)
vlt.UnlockPassphrase = passphrase
value, err := vlt.GetSecret("x")
require.NoError(t, err)
unchanged := bytes.Equal([]byte("value"), value.Bytes())
@@ -98,19 +111,43 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
}
}
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
// `secret vault create` put the mnemonic into the process environment,
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
// leave neither in the environment.
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
t.Setenv(secret.EnvStateDir, t.TempDir())
run := func(args ...string) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// With no terminal to prompt on, this succeeds only if the command
// read both variables
_, err := cli.ExecuteCommandInProcess(args, "", nil)
require.NoError(t, err)
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
_, set := os.LookupEnv(name)
require.False(t, set, "%s is set after %v", name, args)
}
}
run("init")
run("vault", "create", "work")
}
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
// 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
// no unlocker, which neither command would then create again. Each must ask
// for the passphrase before writing anything.
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
//nolint:paralleltest // the cases share cmd
func TestStopAtPassphrasePromptLeavesNothing(t *testing.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, "")
mnemonic := testMnemonicBuffer(t)
// An empty state directory for `secret init`, and one holding the vault
// "default" for `secret vault create work`.
@@ -118,7 +155,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default")
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
require.NoError(t, err)
cmd := &cobra.Command{}
@@ -144,7 +181,12 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Run(tt.command, func(t *testing.T) {
before := snapshotStateDir(t, tt.fs)
err := tt.run(cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir))
// Given no unlock passphrase, both commands prompt for it, which
// fails because the tests do not run in a terminal.
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
c.Mnemonic = mnemonic
err := tt.run(c)
require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs))
+12 -5
View File
@@ -41,6 +41,9 @@ func newCryptoCmd(
cli.cmd = cmd
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return run(cli, args[0], inputFile, outputFile)
},
}
@@ -156,6 +159,8 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get or create the age secret key for this secret
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
if err != nil {
@@ -230,6 +235,8 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if secret exists
secretObj := secret.NewSecret(vlt, secretName)
@@ -308,13 +315,13 @@ func isValidAgeSecretKey(key string) bool {
return err == nil
}
// getSecretValue retrieves the value of a secret using the appropriate
// unlocker
// getSecretValue retrieves the value of a secret with the vault's mnemonic
// when it has one, else with the current unlocker
func (cli *Instance) getSecretValue(
vlt *vault.Vault, secretObj *secret.Secret,
) (*memguard.LockedBuffer, error) {
if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil)
if vlt.Mnemonic != nil {
return secretObj.GetValue(nil, vlt.Mnemonic)
}
unlocker, err := vlt.GetCurrentUnlocker()
@@ -322,5 +329,5 @@ func (cli *Instance) getSecretValue(
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
return secretObj.GetValue(unlocker)
return secretObj.GetValue(unlocker, nil)
}
+5
View File
@@ -76,6 +76,9 @@ func newGenerateSecretCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
},
}
@@ -167,6 +170,8 @@ func (cli *Instance) GenerateSecret(
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Protect the generated secret immediately
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
defer secretBuffer.Destroy()
+19 -18
View File
@@ -39,16 +39,20 @@ func RunInit(cmd *cobra.Command, _ []string) error {
log.Fatalf("failed to initialize CLI: %v", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.Init(cmd)
}
// promptMnemonic reads the mnemonic from the environment or interactively.
// The returned cleanup function must be deferred by the caller.
func promptMnemonic() (string, func(), error) {
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
// or reads it interactively. The returned cleanup function must be deferred
// by the caller.
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
if cli.Mnemonic != nil {
secret.Debug("Using mnemonic from environment variable")
return envMnemonic, func() {}, nil
return cli.Mnemonic, func() {}, nil
}
secret.Debug("Prompting user for mnemonic phrase")
@@ -58,23 +62,23 @@ func promptMnemonic() (string, func(), error) {
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err)
return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
}
fmt.Fprintln(os.Stderr) // Add newline after hidden input
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
}
// setupDefaultVault creates the default vault and derives its long-term
// identity from the mnemonic
func (cli *Instance) setupDefaultVault(
stateDir, mnemonicStr string,
stateDir string, mnemonic *memguard.LockedBuffer,
) (*vault.Vault, *age.X25519Identity, error) {
// Create the default vault - it will handle key derivation internally
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
@@ -92,7 +96,7 @@ func (cli *Instance) setupDefaultVault(
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key", "error", err)
@@ -136,12 +140,13 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
}
// Prompt for mnemonic
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" {
secret.Debug("Empty mnemonic provided")
@@ -162,18 +167,14 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase()
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
defer cleanupPassphrase()
// Create the default vault and derive its long-term key
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
if err != nil {
return err
}
+24 -16
View File
@@ -286,7 +286,7 @@ func TestSecretManagerIntegration(t *testing.T) {
// Test 25: Concurrent operations
// Purpose: Test multiple simultaneous operations
// Expected: Proper locking/synchronization, no corruption
test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv)
test25ConcurrentOperations(t, tempDir, secretPath, testMnemonic, runSecret)
// Test 26: Large secret values
// Purpose: Test with large secret values (e.g., certificates)
@@ -366,8 +366,15 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
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
passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase")
passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker metadata
@@ -382,13 +389,6 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
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
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
verifyFileExists(t, vaultMetadata)
@@ -537,7 +537,8 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
verifyFileExists(t, pubKeyFile)
// Verify passphrase unlocker was created
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase")
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker files
@@ -2009,28 +2010,35 @@ func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic,
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
}
func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
func test25ConcurrentOperations(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) {
t.Helper()
// Make sure we're in default vault
_, err := runSecret("vault", "select", "default")
require.NoError(t, err, "vault select should succeed")
// Run multiple concurrent reads
// Run multiple concurrent reads, as separate processes: within one
// process the first command to read the mnemonic would unset it for
// the others
const numReaders = 5
errCh := make(chan error, numReaders)
for i := range numReaders {
go func(id int) {
output, err := runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic,
}, "get", "database/password")
cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password")
cmd.Env = []string{
secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.Output()
switch {
case err != nil:
errCh <- fmt.Errorf("reader %d failed: %w", id, err)
case strings.TrimSpace(output) == "":
case strings.TrimSpace(string(output)) == "":
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
default:
errCh <- nil
+32 -20
View File
@@ -52,15 +52,18 @@ func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
}
// addAtOnce runs one add of the secret name per value, all at once, and
// returns their errors.
// returns their errors. Each add is given mnemonic, which a forced add
// needs.
func addAtOnce(
fs afero.Fs, stateDir, name string, force bool, values []string,
mnemonic *memguard.LockedBuffer,
) []error {
errs := make(chan error, len(values))
for _, value := range values {
go func() {
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = mnemonic
cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader(value))
@@ -92,9 +95,9 @@ func numbered(prefix string, count int) []string {
// forced adds read the same highest version number and overwrite each
// other's version. With it they behave as if run one after another.
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
mnemonic := testMnemonicBuffer(t)
const adds = 8
@@ -107,14 +110,14 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default")
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
require.NoError(t, err)
// One add creates the secret; the others find that it exists
created := 0
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
numbered("create", adds)) {
numbered("create", adds), mnemonic) {
if err == nil {
created++
} else {
@@ -126,13 +129,15 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
// Every forced add stores a version of its own
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
numbered("force", adds)) {
numbered("force", adds), mnemonic) {
require.NoError(t, err)
}
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
require.NoError(t, err)
vlt.Mnemonic = mnemonic
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
@@ -176,11 +181,11 @@ func (r *readNotifier) Read(p []byte) (int, error) {
// taken the state directory lock before reading, it would hold the lock
// while waiting for encrypt's output, and encrypt would wait for the lock
// to store its key: neither would finish.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptPipedIntoAdd(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default")
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
@@ -283,14 +288,16 @@ func setupEveryCommand(
) (string, string) {
t.Helper()
other, err := vault.CreateVault(fs, testStateDir, "other")
mnemonic := testMnemonicBuffer(t)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
require.NoError(t, err)
otherDir, err := other.GetDirectory()
require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work")
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
@@ -363,7 +370,12 @@ func requireWaitsForLock(
release = sync.OnceFunc(release)
defer release()
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer unlockPassphrase.Destroy()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
cli.UnlockPassphrase = unlockPassphrase
cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader("value"))
cli.cmd.SetOut(io.Discard)
@@ -400,11 +412,8 @@ func requireWaitsForLock(
// TestChangingCommandsWaitForLock checks that each command that changes the
// state directory waits for its lock.
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
func TestChangingCommandsWaitForLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
for _, tc := range []struct {
name string
withUnlocker bool
@@ -468,15 +477,18 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
// key that already exists, which only reads the state directory, finishes
// 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) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
mnemonic := testMnemonicBuffer(t)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default")
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
encrypt.Mnemonic = mnemonic
encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(io.Discard)
@@ -505,11 +517,11 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
// state directory lock by the time it writes its output. Holding it while
// 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.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptStreamsUnlocked(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default")
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
+13 -12
View File
@@ -6,7 +6,6 @@ import (
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@@ -20,9 +19,9 @@ import (
// 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
// directory.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
require.Equal(t, "default", before[testStateDir+"/currentvault"])
@@ -72,6 +71,8 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newFsFromSnapshot(t, before)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
@@ -86,9 +87,9 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
// leaves "default" the current vault.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
@@ -111,10 +112,8 @@ func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
// the secret "x", and the secrets.d of "other" is a link to that of
// "default", so other:x is default:x. Each move must be rejected and leave
// the secret and the links as they were.
//
//nolint:paralleltest // t.Setenv
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
const isSame = "is the same secret on this filesystem"
@@ -150,15 +149,17 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs()
stateDir := t.TempDir()
vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other")
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default")
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
@@ -199,12 +200,12 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
// with "Foo".
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
fs := afero.NewOsFs()
stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default")
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
+35 -16
View File
@@ -33,6 +33,17 @@ const (
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.
// Creating a passphrase unlocker is slow by design, so the vaults are made
// once, by the first test that needs them.
@@ -52,13 +63,12 @@ var (
func newTwoVaultFs(t *testing.T) afero.Fs {
t.Helper()
t.Setenv(secret.EnvMnemonic, testMnemonic)
twoVaultsOnce.Do(func() {
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name)
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
require.NoError(t, err)
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
@@ -163,7 +173,7 @@ func requireRejectedAndUnchanged(
// Moves and imports use --force, so that only the name check stands in
// the way.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
//nolint:paralleltest // the cases share cmd
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// Creating a passphrase unlocker is slow by design, so the vaults are
// created once and each case runs on its own copy of them.
@@ -171,7 +181,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
vaultDir := testStateDir + "/vaults.d/default"
require.Contains(t, before, vaultDir+"/secrets.d/x/")
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
require.Contains(t, before, vaultDir+"/current-unlocker")
require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{}
@@ -259,7 +269,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// `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.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
//nolint:paralleltest // the cases share cmd
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
@@ -297,15 +307,17 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
// `secret vault import ..` wrote a long-term key and an unlocker into the
// state directory itself, and `secret vault select ..` made it the current
// vault. Each command that takes a vault name must reject an invalid one
// before building a path from it. The mnemonic and the passphrase are set,
// and moves and removals use --force, so that only the name check stands
// in the way.
// before building a path from it. The instance is given the mnemonic and
// the passphrase, and moves and removals use --force, so that only the name
// check stands in the way.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
//nolint:paralleltest // the cases share cmd
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
@@ -338,7 +350,12 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
for _, name := range []string{"", ".", "..", "a/b"} {
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
func(c *cli.Instance) error { return tt.run(c, name) })
func(c *cli.Instance) error {
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return tt.run(c, name)
})
})
}
}
@@ -347,14 +364,16 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
// with a version that is not the current one removes that version and
// changes nothing else.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
vlt, err := vault.GetCurrentVault(fs, testStateDir)
require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
// A second version of "x" becomes the current one.
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
require.NoError(t, err)
@@ -388,9 +407,9 @@ func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
// where "work" is also the name of a vault, renames the secret "x" to "work"
// in the current vault and changes nothing else.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
fs := newFsFromSnapshot(t, before)
+24
View File
@@ -81,6 +81,9 @@ func newAddCmd() *cobra.Command {
cli.cmd = cmd // Set the command for stdin access
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
secret.Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force)
@@ -111,6 +114,9 @@ func newGetCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
// Without --version, get the current version. A given
// --version is checked as typed, so an empty one is rejected.
if !cmd.Flags().Changed("version") {
@@ -174,6 +180,9 @@ func newImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ImportSecret(cmd, args[0], sourceFile, force)
},
}
@@ -248,6 +257,9 @@ The source secret is deleted after successful copy.`,
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.MoveSecret(cmd, args[0], args[1], force)
},
}
@@ -354,6 +366,8 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
secret.Debug("Got current vault", "vault_name", vlt.GetName())
// Read secret value directly into protected buffers
@@ -420,6 +434,8 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
value, err := vlt.GetSecret(secretName)
if err != nil {
return err
@@ -448,6 +464,8 @@ func (cli *Instance) GetSecretWithVersion(
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get the secret value
value, err := vlt.GetSecretVersion(secretName, version)
if err != nil {
@@ -633,6 +651,8 @@ func (cli *Instance) ImportSecret(
return err
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Read secret value from the source file into protected buffers
file, err := cli.fs.Open(sourceFile)
if err != nil {
@@ -993,6 +1013,10 @@ func (cli *Instance) moveSecretCrossVault(
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)
_, err = destVault.GetOrDeriveLongTermKey()
if err != nil {
+6 -10
View File
@@ -10,7 +10,6 @@ import (
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
@@ -71,11 +70,8 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
fs := afero.NewMemMapFs()
// Set test mnemonic
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
_, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
require.NoError(t, err)
// Set current vault
@@ -205,7 +201,7 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
//
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
//nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct {
name string
@@ -265,7 +261,7 @@ func TestAddSecretVariousSizes(t *testing.T) {
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
//
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
//nolint:paralleltest // together the subtests lock more than the memlock limit
func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct {
name string
@@ -325,7 +321,7 @@ func TestImportSecretVariousSizes(t *testing.T) {
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
//
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
//nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth
sizes := []int{
@@ -392,9 +388,9 @@ func TestAddSecretBufferGrowth(t *testing.T) {
}
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
//
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
func TestAddSecretStreamingBehavior(t *testing.T) {
t.Parallel()
fs, vlt := newSizeTestVault(t)
// Create a custom reader that simulates slow streaming input
+17 -13
View File
@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@@ -230,6 +229,9 @@ func newUnlockerAddCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
unlockerType := args[0]
// Validate unlocker type
@@ -580,19 +582,19 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// For passphrase unlockers, we don't need the vault to be unlocked
// The CreatePassphraseUnlocker method will handle getting the
// long-term key
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if passphrase is set in environment variable
var passphraseBuffer *memguard.LockedBuffer
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} else {
// The new unlocker gets the passphrase from the environment, which also
// unlocks the current passphrase unlocker, else the one entered here
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
// Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err)
}
defer passphraseBuffer.Destroy()
}
defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
@@ -601,8 +603,8 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
// Auto-select the newly created unlocker
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
// CreatePassphraseUnlocker has already made it the current unlocker
cmd.Printf("Automatically selected as current unlocker\n")
return nil
}
@@ -613,7 +615,8 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
return errKeychainMacOSOnly
}
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
}
@@ -643,7 +646,8 @@ func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
return errSecureEnclaveMacOSOnly
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
}
@@ -707,8 +711,8 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
}
pgpUnlocker, err := secret.CreatePGPUnlocker(
cli.fs, cli.stateDir, gpgKeyID, fingerprint)
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil {
return err
}
+72 -2
View File
@@ -5,18 +5,88 @@ import (
"path/filepath"
"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"
)
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
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
// 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
// lookup moved after anything is written would also come after getting the
// vault's long-term key, which fails first on every platform but macOS
// (https://git.eeqj.de/sneak/secret/issues/88).
// vault's long-term key, which fails first here: this vault's unlockers hold
// no keys.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
+10 -1
View File
@@ -122,7 +122,8 @@ func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
}
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
// without a passphrase there, and returns the key's fingerprint.
// without a passphrase there, with a subkey for encryption, and returns the
// key's fingerprint.
func newTestGPGKey(t *testing.T) string {
t.Helper()
@@ -151,6 +152,14 @@ func newTestGPGKey(t *testing.T) string {
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
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
}
+27 -60
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log"
"os"
"path/filepath"
"slices"
"strings"
@@ -85,6 +84,9 @@ func newVaultCreateCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.CreateVault(cmd, args[0])
},
}
@@ -136,6 +138,9 @@ func newVaultImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.VaultImport(cmd, vaultName)
},
}
@@ -228,28 +233,14 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return nil
}
// setMnemonicEnv sets the mnemonic environment variable and returns a
// function that restores the previous value
func setMnemonicEnv(mnemonicStr string) func() {
originalMnemonic := os.Getenv(secret.EnvMnemonic)
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
return func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}
}
// resolvePassphrase returns the unlock passphrase from the environment or
// prompts the user for it with confirmation
func resolvePassphrase() (*memguard.LockedBuffer, error) {
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
// resolvePassphrase returns the unlock passphrase from the environment,
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The
// returned cleanup function must be deferred by the caller.
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
if cli.UnlockPassphrase != nil {
secret.Debug("Using unlock passphrase from environment variable")
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
return cli.UnlockPassphrase, func() {}, nil
}
secret.Debug("Prompting user for unlock passphrase")
@@ -257,10 +248,10 @@ func resolvePassphrase() (*memguard.LockedBuffer, error) {
// Use secure passphrase input with confirmation
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil {
return nil, fmt.Errorf("failed to read passphrase: %w", err)
return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
}
return passphraseBuffer, nil
return passphraseBuffer, passphraseBuffer.Destroy, nil
}
// CreateVault creates a new vault
@@ -273,30 +264,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
}
defer release()
// Get or prompt for mnemonic
var mnemonicStr string
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
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil {
return err
}
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" {
return errMnemonicEmpty
}
@@ -311,18 +285,14 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
// Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase()
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil {
return err
}
defer passphraseBuffer.Destroy()
// Set mnemonic in environment for CreateVault to use
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
defer restoreMnemonicEnv()
defer cleanupPassphrase()
// Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
if err != nil {
return err
}
@@ -412,11 +382,12 @@ func (cli *Instance) vaultImportPreflight(
}
// Get mnemonic from environment
mnemonic := os.Getenv(secret.EnvMnemonic)
if mnemonic == "" {
if cli.Mnemonic == nil {
return "", "", "", errMnemonicEnvNotSet
}
mnemonic := cli.Mnemonic.String()
// Validate the mnemonic
mnemonicWords := strings.Fields(mnemonic)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
@@ -539,17 +510,13 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
}
// Get passphrase from environment variable
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
if passphraseStr == "" {
passphraseBuffer := cli.UnlockPassphrase
if passphraseBuffer == nil {
return errPassphraseEnvNotSet
}
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
vlt.Unlock(ltIdentity)
+5
View File
@@ -54,6 +54,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ListVersions(cmd, args[0])
},
}
@@ -172,6 +175,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
currentVersion = ""
}
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get long-term key for decrypting metadata
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
+35 -11
View File
@@ -45,6 +45,17 @@ const (
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
// vault with proper buffer protection
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
@@ -61,11 +72,8 @@ func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default")
vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
require.NoError(t, err)
// Derive and store long-term key from mnemonic
@@ -83,11 +91,13 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
require.NoError(t, err)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
@@ -96,6 +106,8 @@ func TestListVersionsCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
@@ -139,8 +151,9 @@ func TestListVersionsCommand(t *testing.T) {
assert.Equal(t, 2, versionLines)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsNonExistentSecret(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -161,8 +174,9 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteVersionCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -174,6 +188,8 @@ func TestPromoteVersionCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
@@ -224,8 +240,9 @@ func TestPromoteVersionCommand(t *testing.T) {
assert.Equal(t, []byte("version-1"), promoted.Bytes())
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -252,11 +269,13 @@ func TestPromoteNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key
setupTestVault(t, fs)
@@ -265,6 +284,8 @@ func TestGetSecretWithVersion(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond)
@@ -298,10 +319,12 @@ func TestGetSecretWithVersion(t *testing.T) {
assert.Equal(t, "version-1", buf.String())
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWritesBinaryValue(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
setupTestVault(t, fs)
@@ -361,8 +384,9 @@ func TestVersionCommandStructure(t *testing.T) {
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
}
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsEmptyOutput(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir)
+5 -5
View File
@@ -5,24 +5,24 @@ package macse
import "errors"
var errNotSupported = errors.New("secure enclave is only supported on macOS")
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
// CreateKey is not supported on non-darwin platforms.
// CreateKey fails: the Secure Enclave needs a macOS build with cgo.
func CreateKey(_ string) ([]byte, string, error) {
return nil, "", errNotSupported
}
// Encrypt is not supported on non-darwin platforms.
// Encrypt fails: the Secure Enclave needs a macOS build with cgo.
func Encrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported
}
// Decrypt is not supported on non-darwin platforms.
// Decrypt fails: the Secure Enclave needs a macOS build with cgo.
func Decrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported
}
// DeleteKey is not supported on non-darwin platforms.
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
func DeleteKey(_ string) error {
return errNotSupported
}
+8 -8
View File
@@ -3,6 +3,7 @@ package secret
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/afero"
@@ -62,13 +63,12 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
return dir, nil
}
// WriteDir calls write to write the files of the directory dir. When dir does
// not exist yet, write writes them into a temporary directory from TempDirFor,
// which is then renamed to dir, so that neither a failure nor a crash leaves
// dir half-written; on a failure the temporary directory is removed, and a
// failure to remove it is returned along with the first. A directory cannot be
// renamed over one that has files in it, so when dir already exists, write
// writes into it in place; dir is then never removed.
// WriteDir calls write to write the files of the new directory dir into a
// temporary directory from TempDirFor, which is then renamed to dir, so that
// neither a failure nor a crash leaves dir half-written; on a failure the
// temporary directory is removed, and a failure to remove it is returned
// along with the first. A directory cannot be replaced in one rename, so if
// dir already exists, WriteDir fails without calling write.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir)
if err != nil {
@@ -76,7 +76,7 @@ func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
}
if exists {
return write(dir)
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
}
// Create the directory the finished one is renamed into
+174 -37
View File
@@ -191,6 +191,22 @@ func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
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
// and returns it.
func writeLongTermKey(
@@ -219,7 +235,7 @@ func newVaultWithSecret(
) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name)
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -329,14 +345,14 @@ func TestRemoveDirAtomic(t *testing.T) {
// 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.
func TestLongestNames(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
const longestName = 255
fs := afero.NewOsFs()
name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name)
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long"))
@@ -361,13 +377,13 @@ func TestLongestNames(t *testing.T) {
// 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
// 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) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
src := newVaultWithSecret(t, base, stateDir, "source", "new")
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
@@ -400,13 +416,13 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
// directory directly in secrets.d or in a versions directory. Those are
// 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.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestTempDirsStayOutOfListings(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
newVaultWithSecret(t, base, stateDir, "default", "first")
@@ -419,6 +435,7 @@ func TestTempDirsStayOutOfListings(t *testing.T) {
return nil
}}
vlt := vault.NewVault(fs, stateDir, "default")
vlt.Mnemonic = testMnemonicBuffer(t)
value := memguard.NewBufferFromBytes([]byte("second"))
defer value.Destroy()
@@ -527,13 +544,13 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
// 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
// missing.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestCurrentFilesNeverMissing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
@@ -625,11 +642,11 @@ func TestWriteFileAtomicTempFile(t *testing.T) {
// anything, so that it never leaves a partial unlocker, nor breaks the one
// it would replace.
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from
t.Setenv(secret.EnvMnemonic, "")
t.Parallel()
// No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -650,30 +667,31 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// 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
// leaves no partial unlocker.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
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)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
// The vault has no unlocker yet, so any directory in here is
// the new one
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
fs := hookFs{Fs: base, before: func(string, string) error {
exists, err := afero.DirExists(base, unlockerDir)
require.NoError(t, err)
if exists {
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir),
for _, name := range dirNames(t, base, unlockersDir) {
assert.ElementsMatch(t, files,
dirNames(t, base, filepath.Join(unlockersDir, name)),
"unlocker directory visible before it was complete")
}
@@ -683,14 +701,133 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, stateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
hooked := vault.NewVault(fs, stateDir, testVaultName)
hooked.Mnemonic = vlt.Mnemonic
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir))
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
})
}
}
// 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
// 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
@@ -736,10 +873,10 @@ func TestWriteDirFailureLeavesNothing(t *testing.T) {
}
}
// TestWriteDirKeepsExistingDir makes writing into a directory that already
// exists fail, and checks that the directory, with what was in it, is still
// there: WriteDir writes into it in place and never removes it.
func TestWriteDirKeepsExistingDir(t *testing.T) {
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
// write, when the directory already exists, and leaves the directory as it
// was: it never writes into a directory in place.
func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
@@ -747,17 +884,17 @@ func TestWriteDirKeepsExistingDir(t *testing.T) {
t.Parallel()
fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "passphrase")
target := filepath.Join(dir, "unlockers.d", "existing")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(got string) error {
assert.Equal(t, target, got)
err := secret.WriteDir(fs, target, func(string) error {
t.Error("write called for a directory that exists")
return errInjected
return nil
})
require.ErrorIs(t, err, errInjected)
require.ErrorIs(t, err, os.ErrExist)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
})
}
+6
View File
@@ -16,6 +16,12 @@ const (
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
// EnvGPGKeyID is the environment variable for providing the 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
+26 -4
View File
@@ -1,5 +1,6 @@
//go:build darwin && cgo
//go:build darwin
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
package secret
import (
@@ -8,6 +9,7 @@ import (
"testing"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@@ -26,7 +28,9 @@ type realVault struct {
func (v *realVault) GetDirectory() (string, error) {
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
}
func (v *realVault) GetName() string { return v.name }
func (v *realVault) GetName() string { return v.name }
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
@@ -34,10 +38,23 @@ func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
panic("not used")
}
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
panic("not used")
}
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
panic("not used")
}
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) CreatePassphraseUnlocker(
*memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
@@ -68,6 +85,9 @@ func createRealVault(
}
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
//nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
@@ -83,10 +103,12 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault)
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err)
defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()),
+186 -196
View File
@@ -1,21 +1,22 @@
//go:build darwin && cgo
//go:build darwin
package secret
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"regexp"
"runtime"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
"github.com/spf13/afero"
)
@@ -25,15 +26,31 @@ const (
//
//nolint:revive // ALL_CAPS is intentional for this constant
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
// Allows alphanumeric characters, dots, hyphens, and underscores only
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
type KeychainUnlockerMetadata struct {
UnlockerMetadata
// Keychain item name
KeychainItemName string `json:"keychainItemName"`
}
@@ -45,6 +62,17 @@ type KeychainUnlocker struct {
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
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting keychain unlocker identity",
@@ -52,44 +80,12 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
slog.String("unlocker_type", k.GetType()),
)
// Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName()
keychainData, err := k.readKeychainData()
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)
return nil, err
}
defer keychainData.AgePrivKeyPassphrase.Destroy()
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
// Step 4: Read the encrypted age private key from filesystem
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
@@ -110,6 +106,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase",
"unlocker_id", k.GetID())
agePrivKeyBuffer, err := DecryptWithPassphrase(
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil {
@@ -146,7 +143,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// GetType implements Unlocker interface
func (k *KeychainUnlocker) GetType() string {
return "keychain"
return keychainUnlockerType
}
// GetMetadata implements Unlocker interface
@@ -188,7 +185,9 @@ func (k *KeychainUnlocker) Remove() error {
// Step 2: Remove from keychain
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",
"error", err, "keychain_item", keychainItemName)
@@ -197,7 +196,9 @@ func (k *KeychainUnlocker) Remove() error {
// Step 3: Remove 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",
"error", err, "directory", k.Directory)
@@ -210,34 +211,71 @@ func (k *KeychainUnlocker) Remove() error {
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
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
// Load the metadata
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(k.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
}
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 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
func generateKeychainUnlockerName(vaultName string) (string, error) {
hostname, err := os.Hostname()
@@ -245,47 +283,21 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: secret-<vault>-<hostname>-<date>
enrollmentDate := time.Now().Format("2006-01-02")
// Format: secret-<vault>-<hostname>-<time>
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
}
// getLongTermPrivateKey retrieves the long-term private key either from
// environment or current unlocker
// getLongTermPrivateKey derives the long-term private key from mnemonic when
// it is not nil, else gets it through the current unlocker, which is given
// passphrase when it is a passphrase unlocker.
// Returns a LockedBuffer to ensure the private key is protected in memory
func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface,
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
// Check if mnemonic is available in environment variable
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
if mnemonic != nil {
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
// Get the vault to access current unlocker
@@ -294,6 +306,10 @@ func getLongTermPrivateKey(
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
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
@@ -302,6 +318,7 @@ func getLongTermPrivateKey(
// Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte
switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker
@@ -332,8 +349,7 @@ func getLongTermPrivateKey(
}
default:
return nil, fmt.Errorf(
"unsupported current unlocker type for keychain unlocker creation")
return nil, errUnsupportedCurrentUnlocker
}
// Decrypt long-term private key using current unlocker
@@ -347,10 +363,52 @@ func getLongTermPrivateKey(
return ltPrivKeyBuffer, nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
// the vault's derivation index, for getLongTermPrivateKey and
// 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
if err := checkMacOSAvailable(); err != nil {
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
@@ -392,6 +450,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
// Step 3: Encrypt age private key with the generated passphrase
// Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy()
@@ -402,7 +461,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
// Step 4: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, err
}
@@ -429,12 +488,23 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
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
keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{
Type: "keychain",
Type: keychainUnlockerType,
CreatedAt: time.Now(),
Flags: []string{"keychain", "macos"},
Flags: []string{keychainUnlockerType, macOSFlag},
},
KeychainItemName: keychainItemName,
}
@@ -447,27 +517,29 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
// Step 8: Write the unlocker's files and store the data in the keychain,
// the metadata last
err = WriteDir(fs, unlockerDir, func(dir string) error {
pubPath := filepath.Join(dir, "pub.txt")
if err := WriteFileAtomic(fs, pubPath, []byte(ageRecipient)); err != nil {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
privPath := filepath.Join(dir, "priv.age")
if err := WriteFileAtomic(fs, privPath, encryptedAgePrivKey); err != nil {
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
ltKeyPath := filepath.Join(dir, "longterm.age")
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtPrivKeyToAge); err != nil {
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
metadataPath := filepath.Join(dir, "unlocker-metadata.json")
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
@@ -484,106 +556,24 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}, 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
// command execution
func validateKeychainItemName(itemName string) error {
if itemName == "" {
return fmt.Errorf("keychain item name cannot be empty")
return errKeychainItemNameEmpty
}
if !keychainItemNameRegex.MatchString(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 fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
}
return nil
+104
View File
@@ -0,0 +1,104 @@
//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
@@ -0,0 +1,30 @@
//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
}
+4 -9
View File
@@ -1,4 +1,4 @@
//go:build !darwin || !cgo
//go:build !darwin
package secret
@@ -76,13 +76,8 @@ func (k *KeychainUnlocker) Remove() error {
}
// CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(
_ afero.Fs, _ VaultInterface,
) (*memguard.LockedBuffer, error) {
func CreateKeychainUnlocker(
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
+34 -19
View File
@@ -19,6 +19,17 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"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.
func writeTestPublicKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
@@ -163,7 +174,7 @@ func newTestPassphraseUnlocker(
return unlocker, ageIdentity, unlockerDir
}
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
//nolint:paralleltest // subtests share real-FS state, order matters
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// This test uses real filesystem
if os.Getenv("CI") == "true" {
@@ -195,38 +206,42 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
})
// Set test environment variable (cleaned up automatically)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
// 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)
}
unlocker.Passphrase = passphrase
// Verify the identity matches what we expect
expectedPubKey := ageIdentity.Recipient().String()
// Test getting identity with the passphrase the unlocker was given,
// twice: using it must leave it intact for the next use
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
for range 2 {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity with passphrase: %v", err)
}
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
// Verify the identity matches what we expect
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
}
})
// Unset the environment variable to test interactive prompt
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
unlocker.Passphrase = nil
// Test getting identity from prompt (this would require mocking 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
// when no passphrase is available.
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
// This should fail since we're not in an interactive terminal
_, err := unlocker.GetIdentity()
if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var")
t.Errorf("Should have failed to get identity without a passphrase")
}
})
+8 -18
View File
@@ -3,7 +3,6 @@ package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
@@ -135,28 +134,19 @@ func (p *PassphraseUnlocker) Remove() error {
return nil
}
// getPassphrase retrieves the passphrase from memory, environment, or
// user input. Returns a LockedBuffer for secure memory handling
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
// the passphrase. The caller must destroy the returned buffer.
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
// First check if we already have the passphrase
if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Return a copy of the passphrase buffer
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
// Not NewBufferFromBytes, which would wipe p.Passphrase
passphrase := memguard.NewBuffer(p.Passphrase.Size())
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, nil
}
Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return secureBuffer, nil
}
Debug("No passphrase in environment, prompting user")
Debug("No passphrase in memory, prompting user")
// Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
+5 -3
View File
@@ -268,7 +268,6 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
fs := afero.NewOsFs()
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvGPGKeyID, keyID)
// Set up vault structure for testing
@@ -328,8 +327,10 @@ func testCreatePGPUnlocker(
})
defer timer.Stop()
mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -379,7 +380,8 @@ func testCreatePGPUnlocker(
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint)
pgpUnlocker, err := secret.CreatePGPUnlocker(
fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
+20 -13
View File
@@ -209,21 +209,20 @@ func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
}
// generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and date
// based on hostname and time
func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: hostname-pgp-YYYY-MM-DD
enrollmentDate := time.Now().Format("2006-01-02")
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
}
// pgpUnlockerDir returns the current vault and the directory in it for a
// new PGP unlocker, named after the host and the day.
// new PGP unlocker, named after the host and the time.
//
//nolint:ireturn // the vault is only available behind VaultInterface
func pgpUnlockerDir(
@@ -235,7 +234,7 @@ func pgpUnlockerDir(
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
}
// Generate the unlocker name based on hostname and date
// Generate the unlocker name based on hostname and time
unlockerName, err := generatePGPUnlockerName()
if err != nil {
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
@@ -254,9 +253,12 @@ func pgpUnlockerDir(
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
// 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
// leaves no partial unlocker.
// leaves no partial unlocker. The long-term key comes from mnemonic when it
// is not nil, else from the current unlocker, which is given passphrase when
// it is a passphrase unlocker.
func CreatePGPUnlocker(
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*PGPUnlocker, error) {
err := checkGPGAvailable()
if err != nil {
@@ -268,6 +270,10 @@ func CreatePGPUnlocker(
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
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
@@ -277,7 +283,7 @@ func CreatePGPUnlocker(
// Step 2: Encrypt the long-term private key to the new keypair, and the
// keypair's private key to the GPG key
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
fs, vault, ageIdentity, gpgKeyID)
vault, ageIdentity, gpgKeyID)
if err != nil {
return nil, err
}
@@ -316,14 +322,15 @@ func CreatePGPUnlocker(
// to the new PGP unlocker's age keypair, and that keypair's private key
// encrypted to the GPG key gpgKeyID.
func encryptPGPUnlockerKeys(
fs afero.Fs, vault VaultInterface,
ageIdentity *age.X25519Identity, gpgKeyID string,
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
) ([]byte, []byte, error) {
// Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
// From the mnemonic or the current unlocker, as for a passphrase unlocker
ltIdentity, err := vault.GetOrDeriveLongTermKey()
if err != nil {
return nil, nil, err
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
}
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient(
+43 -6
View File
@@ -7,6 +7,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -40,15 +41,14 @@ func installFakeGPG(t *testing.T) {
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
// 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
// fails because on macOS there is no mnemonic and no current unlocker, and
// on every other platform it always fails
// (https://git.eeqj.de/sneak/secret/issues/88).
// fails because there is no mnemonic and no current unlocker.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t)
t.Setenv(secret.EnvMnemonic, "")
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -58,10 +58,47 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
}}
_, err = secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint)
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
require.Error(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
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()))
}
+18 -11
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@@ -35,6 +34,12 @@ type VaultInterface interface {
GetName() string
GetFilesystem() afero.Fs
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(
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
}
@@ -76,9 +81,12 @@ func NewSecret(vault VaultInterface, name string) *Secret {
}
}
// GetValue retrieves and decrypts the current version's value using the
// provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// GetValue retrieves and decrypts the current version's value, with the
// vault's long-term key derived from mnemonic when it is not nil, else
// obtained through unlocker
func (s *Secret) GetValue(
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
@@ -113,9 +121,8 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// Create version object
version := NewVersion(s.vault, s.Name, currentVersion)
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
return s.getValueViaMnemonic(version, envMnemonic)
if mnemonic != nil {
return s.getValueViaMnemonic(version, mnemonic.String())
}
Debug("Using unlocker for vault access", "secret_name", s.Name)
@@ -209,11 +216,11 @@ func (s *Secret) Exists() (bool, error) {
}
// getValueViaMnemonic derives the vault's long-term key from the
// mnemonic in the environment and decrypts the version value with it.
// mnemonic and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic(
version *Version, envMnemonic string,
version *Version, mnemonic string,
) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic from environment for direct long-term key derivation",
Debug("Using mnemonic for direct long-term key derivation",
"secret_name", s.Name)
// Get vault directory to read metadata
@@ -250,7 +257,7 @@ func (s *Secret) getValueViaMnemonic(
)
// Use mnemonic with the vault's derivation index from metadata
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil {
Debug("Failed to derive long-term key from mnemonic for secret",
"error", err, "secret_name", s.Name)
+54 -23
View File
@@ -2,6 +2,7 @@
package secret
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -22,7 +23,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
var (
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock")
)
@@ -32,6 +33,7 @@ type MockVault struct {
fs afero.Fs
directory string
derivationIndex uint32
mnemonic *memguard.LockedBuffer
}
func (m *MockVault) GetDirectory() (string, error) {
@@ -61,12 +63,11 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index
mnemonic := os.Getenv(EnvMnemonic)
if mnemonic == "" {
if m.mnemonic == nil {
return errMnemonicNotSet
}
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil {
return err
}
@@ -107,6 +108,16 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
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(
_ *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
@@ -234,13 +245,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
}
}
//nolint:paralleltest // uses t.Setenv (process-global environment)
//nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
// Set test mnemonic for direct encryption/decryption
t.Setenv(EnvMnemonic, testMnemonicValue)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
@@ -254,6 +265,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
fs: fs,
directory: vaultDir,
derivationIndex: 0,
mnemonic: mnemonic,
}
// Test data
@@ -310,26 +322,45 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
})
}
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
// This test demonstrates the bug where GetValue uses hardcoded index 0
// instead of the vault's actual derivation index when using environment mnemonic
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
// GetValue, given the mnemonic, derives the long-term key at the derivation
// index in the vault's metadata. At index 0 it could not decrypt the secret,
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
// Set up test mnemonic
t.Setenv(EnvMnemonic, testMnemonicValue)
fs := afero.NewMemMapFs()
vaultDir := "/test-config/vaults.d/test-vault"
// Create temporary directory for vaults
fs := afero.NewOsFs()
tempDir, err := afero.TempDir(fs, "", "secret-test-")
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
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)
defer func() {
_ = fs.RemoveAll(tempDir)
}()
secretName, secretValue := "x", "value"
stateDir := filepath.Join(tempDir, ".secret")
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
err = vlt.AddSecret(secretName,
memguard.NewBufferFromBytes([]byte(secretValue)), false)
require.NoError(t, err)
// This test is now in the integration test file where it can use real vaults
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
t.Log("This test demonstrates the bug in the integration test file")
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, secretValue, value.String())
}
+17 -56
View File
@@ -4,17 +4,14 @@ package secret
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -30,8 +27,6 @@ const (
seLongtermFilename = "longterm.age.se"
)
var errNotMacOS = errors.New("keychain unlockers are only supported on macOS")
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata
@@ -205,33 +200,26 @@ func generateSEKeyLabel(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
enrollmentDate := time.Now().UTC().Format("2006-01-02")
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf(
"%s.%s-%s-%s",
seKeyLabelPrefix,
vaultName,
hostname,
enrollmentDate,
enrollmentTime,
), 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.
// The vault's long-term private key is encrypted directly by the Secure Enclave
// 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(
fs afero.Fs,
stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
err := checkMacOSAvailable()
if err != nil {
@@ -260,7 +248,7 @@ func CreateSecureEnclaveUnlocker(
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
@@ -291,7 +279,7 @@ func CreateSecureEnclaveUnlocker(
UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType,
CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, "macos"},
Flags: []string{seUnlockerType, macOSFlag},
},
SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash,
@@ -340,47 +328,16 @@ func writeSEUnlockerFiles(
return nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker.
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker.
func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
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
if mnemonic != nil {
return deriveLongTermPrivateKey(fs, vault, mnemonic)
}
currentUnlocker, err := vault.GetCurrentUnlocker()
@@ -388,6 +345,10 @@ func getLongTermKeyForSE(
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf(
+2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -80,6 +81,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
_, _ *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}
+1 -1
View File
@@ -78,7 +78,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
assert.Nil(t, unlocker)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
+30 -121
View File
@@ -1,5 +1,6 @@
//go:build darwin && cgo
//go:build darwin
//nolint:testpackage // white-box test of unexported validateKeychainItemName
package secret
import (
@@ -7,138 +8,46 @@ import (
)
func TestValidateKeychainItemName(t *testing.T) {
t.Parallel()
tests := []struct {
name string
itemName string
wantErr bool
}{
// Valid cases
{
name: "valid simple name",
itemName: "my-secret-key",
wantErr: false,
},
{
name: "valid name with dots",
itemName: "com.example.app.key",
wantErr: false,
},
{
name: "valid name with underscores",
itemName: "my_secret_key_123",
wantErr: false,
},
{
name: "valid alphanumeric",
itemName: "Secret123Key",
wantErr: false,
},
{
name: "valid with hyphen at start",
itemName: "-my-key",
wantErr: false,
},
{
name: "valid with dot at start",
itemName: ".hidden-key",
wantErr: false,
},
{name: "valid simple name", itemName: "my-secret-key", wantErr: false},
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
// Invalid cases
{
name: "empty item name",
itemName: "",
wantErr: true,
},
{
name: "item name with spaces",
itemName: "my secret key",
wantErr: true,
},
{
name: "item name with semicolon",
itemName: "key;rm -rf /",
wantErr: true,
},
{
name: "item name with pipe",
itemName: "key|cat /etc/passwd",
wantErr: true,
},
{
name: "item name with backticks",
itemName: "key`whoami`",
wantErr: true,
},
{
name: "item name with dollar sign",
itemName: "key$(whoami)",
wantErr: true,
},
{
name: "item name with quotes",
itemName: "key\"name",
wantErr: true,
},
{
name: "item name with single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
{name: "empty item name", itemName: "", wantErr: true},
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
{name: "item name with quotes", itemName: "key\"name", wantErr: true},
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
{name: "item name with newline", itemName: "key\nname", wantErr: true},
{name: "item name with carriage return", itemName: "key\rname", wantErr: true},
{name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
{name: "item name with parentheses", itemName: "key(test)", wantErr: true},
{name: "item name with brackets", itemName: "key[test]", wantErr: true},
{name: "item name with asterisk", itemName: "key*", wantErr: true},
{name: "item name with question mark", itemName: "key?", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
+8
View File
@@ -87,6 +87,14 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
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(
_ *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) {
+25 -20
View File
@@ -8,7 +8,6 @@ import (
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
@@ -41,46 +40,49 @@ func deriveVaultIdentity(
return ltIdentity
}
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestVaultWithRealFilesystem(t *testing.T) {
t.Parallel()
// Create a temporary directory for our tests
tempDir := t.TempDir()
// Use the real filesystem
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)
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
t.Parallel()
testCurrentVaultFileHandling(t, fs, tempDir)
})
// Test secret operations with deeply nested paths
t.Run("DeepPathSecrets", func(t *testing.T) {
t.Parallel()
testDeepPathSecrets(t, fs, tempDir)
})
// Test key caching in GetOrDeriveLongTermKey
t.Run("KeyCaching", func(t *testing.T) {
t.Parallel()
testKeyCaching(t, fs, tempDir)
})
// Test vault name validation
t.Run("VaultNameValidation", func(t *testing.T) {
t.Parallel()
testVaultNameValidation(t, fs, tempDir)
})
// Test multiple vaults and switching between them
t.Run("MultipleVaults", func(t *testing.T) {
t.Parallel()
testMultipleVaults(t, fs, tempDir)
})
// Test adding a secret in one vault and verifying it's not visible in
// another
t.Run("VaultIsolation", func(t *testing.T) {
t.Parallel()
testVaultIsolation(t, fs, tempDir)
})
}
@@ -96,7 +98,8 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
}
// Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -141,9 +144,10 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault - CreateVault now handles public key when
// mnemonic is in env
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -216,9 +220,10 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create a test vault - CreateVault now handles public key when
// mnemonic is in env
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
// Create a test vault - CreateVault writes the public key derived from
// the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -319,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
}
for _, name := range validNames {
_, err := vault.CreateVault(fs, stateDir, name)
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err != nil {
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
}
@@ -335,7 +340,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
}
for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name)
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err == nil {
t.Errorf("Expected error creating vault with invalid name %q, "+
"but got none", name)
@@ -356,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
// Create three vaults
vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames {
_, err := vault.CreateVault(fs, stateDir, name)
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err)
}
@@ -404,14 +409,14 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err)
}
// Create two vaults - CreateVault now handles public key when mnemonic
// is in env
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
// Create two vaults - CreateVault writes the public key derived from
// the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault1: %v", err)
}
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault2: %v", err)
}
+9 -10
View File
@@ -44,15 +44,12 @@ var errUnexpectedValue = errors.New("unexpected value")
// TestVersionIntegrationWorkflow tests the complete version workflow
//
//nolint:paralleltest // t.Setenv forbids parallel subtests
//nolint:paralleltest // the subtests are steps that build on each other
func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
@@ -351,9 +348,9 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
}
// TestVersionConcurrency tests concurrent version operations
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionConcurrency(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Set up vault
@@ -366,6 +363,8 @@ func TestVersionConcurrency(t *testing.T) {
// Test concurrent reads
t.Run("concurrent_reads", func(t *testing.T) {
t.Parallel()
done := make(chan bool, 10)
errCh := make(chan error, 10)
@@ -403,9 +402,9 @@ func TestVersionConcurrency(t *testing.T) {
}
// TestVersionCompatibility tests that old secrets without versions still work
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionCompatibility(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Set up vault
+23 -16
View File
@@ -3,7 +3,6 @@ package vault
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
@@ -11,6 +10,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -155,19 +155,18 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
// It returns the derivation index, public key hash, and family hash.
func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer,
) (uint32, string, string, error) {
// Check if mnemonic is available in environment
mnemonic := os.Getenv(secret.EnvMnemonic)
if mnemonic == "" {
secret.Debug("No mnemonic in environment, vault created without long-term key",
if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key",
"vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided
return 0, "", "", nil
}
secret.Debug("Mnemonic found in environment, deriving long-term key",
"vault", vaultName)
mnemonic := mnemonicBuffer.String()
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
// Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
@@ -208,12 +207,17 @@ func processMnemonicForVault(
return derivationIndex, publicKeyHash, familyHash, nil
}
// CreateVault creates a new vault and selects it as the current vault. It
// refuses a vault that already exists before writing anything: creating it
// again would replace its keys, and its secrets could no longer be
// decrypted. The commands that call it hold the state directory lock, so no
// other command can create the vault between the check and the writes.
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the
// returned vault has it as its Mnemonic; when it is nil, the vault has no
// long-term key until one is imported. It refuses a vault that already
// exists before writing anything: creating it again would replace its keys,
// and its secrets could no longer be decrypted. The commands that call it
// hold the state directory lock, so no other command can create the vault
// between the check and the writes.
func CreateVault(
fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
err := ValidateVaultName(name)
@@ -263,7 +267,7 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name)
fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return nil, err
}
@@ -292,7 +296,10 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Create and return the vault
secret.Debug("Successfully created vault", "name", name)
return NewVault(fs, stateDir, name), nil
vlt := NewVault(fs, stateDir, name)
vlt.Mnemonic = mnemonic
return vlt, nil
}
// SelectVault selects the given vault as the current vault
+6 -10
View File
@@ -297,14 +297,14 @@ func TestSampleHashCalculation(t *testing.T) {
}
func TestWorkflowMismatch(t *testing.T) {
t.Parallel()
// Create a temporary directory for testing
tempDir := t.TempDir()
fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command)
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
_, err := vault.CreateVault(fs, tempDir, "default")
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault with mnemonic: %v", err)
}
@@ -321,19 +321,15 @@ func TestWorkflowMismatch(t *testing.T) {
metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
t.Setenv("SB_SECRET_MNEMONIC", "")
_, err = vault.CreateVault(fs, tempDir, "work")
_, err = vault.CreateVault(fs, tempDir, "work", nil)
if err != nil {
t.Fatalf("Failed to create vault without mnemonic: %v", err)
}
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
// Simulate the vault import process
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
// Get the next available derivation index for this mnemonic
// Simulate the vault import process: get the next available derivation
// index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
if err != nil {
t.Fatalf("Failed to get next derivation index: %v", err)
+13 -14
View File
@@ -3,7 +3,6 @@ package vault_test
import (
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
@@ -13,15 +12,13 @@ import (
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts.
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
// Add a legitimate secret so the vault is set up
@@ -41,6 +38,8 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretVersion(name, "")
require.Error(t, err,
"GetSecretVersion should reject malicious name: %s", name)
@@ -53,12 +52,12 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
// GetSecretVersion) also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd")
@@ -68,15 +67,13 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts.
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
maliciousNames := []string{
@@ -87,6 +84,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretObject(name)
require.Error(t, err, "GetSecretObject should reject: %s", name)
require.Contains(t, err.Error(), "invalid secret name")
+14 -19
View File
@@ -41,14 +41,6 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon about"
// envTestMnemonic is the (deliberately different) mnemonic placed in the
// environment; the vault is unlocked manually with the derived key in
// createTestVaultWithKey.
//
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon about"
// Shared fixtures for white-box tests in this package.
const (
testStateDir = "/test/state"
@@ -73,11 +65,8 @@ func addTestSecretToVault(
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, envTestMnemonic)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
// Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil)
require.NoError(t, err)
// Derive and store long-term key from mnemonic
@@ -98,8 +87,9 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
return vault
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretCreatesVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -137,8 +127,9 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
assert.Equal(t, expectedValue, retrievedValue.Bytes())
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretMultipleVersions(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -174,8 +165,9 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
assert.Equal(t, []byte("version-2"), value.Bytes())
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetSecretVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -220,8 +212,9 @@ func TestVaultGetSecretVersion(t *testing.T) {
require.ErrorIs(t, err, ErrVersionNotFound)
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultVersionTimestamps(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -303,8 +296,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
@@ -319,8 +313,9 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found")
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestUpdateVersionMetadata(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
// Create vault with long-term key
+66 -11
View File
@@ -2,8 +2,10 @@ package vault
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@@ -70,7 +72,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.Debug("Creating passphrase unlocker instance",
"unlocker_type", metadata.Type)
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
passphraseUnlocker.Passphrase = v.UnlockPassphrase
unlocker = passphraseUnlocker
case "pgp":
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
@@ -101,7 +105,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
// resolveUnlockerDirectory reads the current-unlocker file to get the
// unlocker directory path
// The file contains just the unlocker name (e.g., "passphrase")
// The file contains just the name of the unlocker's directory in unlockers.d
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -339,7 +343,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
// 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
func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer,
@@ -351,13 +358,23 @@ func (v *Vault) CreatePassphraseUnlocker(
// 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
// get it changes nothing, even when replacing the current unlocker.
// get it changes nothing.
ltIdentity, err := v.GetOrDeriveLongTermKey()
if err != nil {
return nil, fmt.Errorf("failed to get long-term key: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// 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
unlockerIdentity, err := age.GenerateX25519Identity()
@@ -377,7 +394,7 @@ func (v *Vault) CreatePassphraseUnlocker(
metadata := UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Now(),
CreatedAt: createdAt,
Flags: []string{},
}
@@ -395,16 +412,54 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, err
}
// Create the unlocker instance
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
// Select the new unlocker by its directory, not by its ID: an old
// passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Select this unlocker as current
err = v.SelectUnlocker(unlocker.GetID())
err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
[]byte(filepath.Base(unlockerDir)))
if err != nil {
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
}
return unlocker, nil
for _, oldDir := range oldDirs {
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
+25 -7
View File
@@ -3,12 +3,12 @@ package vault
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -18,6 +18,13 @@ type Vault struct {
fs afero.Fs
stateDir string
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
@@ -56,6 +63,18 @@ func (v *Vault) ClearLongTermKey() {
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
// from available sources
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
@@ -66,9 +85,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
// Try to derive from environment mnemonic first
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
if v.Mnemonic != nil {
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
}
// No mnemonic available, try to use current unlocker
@@ -181,9 +199,9 @@ func (v *Vault) NumSecrets() (int, error) {
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
// mnemonic, verifies it against the vault metadata, and caches it in memory.
func (v *Vault) deriveLongTermKeyFromMnemonic(
envMnemonic string,
mnemonic string,
) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic from environment for long-term key derivation",
secret.Debug("Using mnemonic for long-term key derivation",
"vault_name", v.Name)
// Load vault metadata to get the derivation index
@@ -199,7 +217,7 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
}
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key from mnemonic",
"error", err, "vault_name", v.Name)
+19 -10
View File
@@ -27,12 +27,19 @@ const (
testPassphrase = "test-passphrase"
)
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
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)
// 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
}
//nolint:paralleltest // order-dependent subtests forbid parallel
func TestVaultOperations(t *testing.T) {
// Use in-memory filesystem
fs := afero.NewMemMapFs()
@@ -64,7 +71,8 @@ func TestVaultOperations(t *testing.T) {
func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -221,6 +229,8 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
}
// Test vault unlocking (should happen automatically via mnemonic)
vlt.Mnemonic = testMnemonicBuffer(t)
if vlt.Locked() {
_, err := vlt.UnlockVault()
if err != nil {
@@ -281,15 +291,14 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
}
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
t.Parallel()
// Use in-memory filesystem
fs := afero.NewMemMapFs()
// Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t))
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
+4 -3
View File
@@ -5,9 +5,10 @@
# stage of Dockerfile.lint, rebuilt on every run as script/lint does.
#
# Cgo is off: compiling cgo code for macOS needs Apple's SDK headers. That
# leaves out the files built only with cgo on macOS: the keychain unlocker
# (keychainunlocker.go and its tests) and the Secure Enclave bindings
# (internal/macse). Nothing on Linux checks those.
# leaves out the files built only with cgo on macOS: the keychain unlocker's
# calls into the keychain (keychainunlocker_cgo.go, and
# keychainunlocker_test.go) and the Secure Enclave bindings (internal/macse).
# Nothing on Linux checks those.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"