Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abde576300 | ||
|
|
7e4e0f7806 | ||
|
|
db7d2c952e | ||
|
|
5ca615a7a6 | ||
|
|
62967f28d0 |
+66
-2
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user