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