19 Commits
Author SHA1 Message Date
clawbot 2adc588ace Say the mnemonic still opens a vault its unlocker cannot (closes #47)
check / check (push) Failing after 2s
When a vault cannot be opened through its current unlocker, the error now
ends by naming the vault, saying that it still opens with its mnemonic,
and that 'secret unlocker add passphrase' run with SB_SECRET_MNEMONIC set
gives it a new unlocker, after 'secret vault select' when it is not the
current vault. Only when the vault metadata records the key the mnemonic
derives, and not when the passphrase could not be read. 'secret encrypt'
and 'secret decrypt' read the key secret through vault.GetSecret, and
Secret.GetValue with its helpers is removed. An unreadable 'current'
file's error names 'secret version list' and 'secret version promote'.
Causes stay wrapped.

Model: opus-5-5
2026-10-04 21:59:02 +02:00
clawbot 0e6a4afb71 Make an unlocker's ID the name of its directory (closes #98)
check / check (push) Failing after 2s
Keychain and Secure Enclave unlocker IDs were the creation time to the
minute plus the host name, and passphrase unlocker IDs the time to the
minute, so two created within one minute shared an ID, and `unlocker
select`, `unlocker remove` and the selection after `unlocker add` acted on
the older one. Every unlocker's ID is now its directory name, unique in
its vault. `vault.ListUnlockers` returns each unlocker's metadata keyed by
that name, so `unlocker list` and shell completion no longer find IDs by
matching metadata. PGP unlocker IDs were `pgp-<fingerprint>`; a second
PGP unlocker for one key is refused by comparing fingerprints in metadata.

Model: opus-5-5
2026-10-04 21:25:00 +02:00
clawbot 1a23fd3125 Make the README's storage and file format text match the code (closes #102)
check / check (push) Failing after 2s
The directory tree shows `current` and `currentvault` as plain files holding
a name, a version's metadata as the encrypted `metadata.age`, the real state
directory under the user's configuration directory, and the `lock` file.
`version promote` rewrites `current`. File Formats tells unencrypted vault
and unlocker metadata from encrypted version metadata; `pub.age` is plain
text and vault metadata holds no vault name. Unlocker bullets lose Touch ID
claims the code does not set up, and the Secure Enclave only decrypts.
Per-version keys no longer claim forward secrecy. Testing lists only
`make test`.

Model: opus-5-5
2026-10-04 20:58:42 +02:00
clawbot 23dcea83f9 Create a vault whole in a temporary directory, then select it (closes #105)
check / check (push) Failing after 2s
vault.CreateVault takes the unlocker passphrase and writes the vault
directory, its metadata, long-term public key and passphrase unlocker
into a temporary directory, renames that into vaults.d once complete,
and only then makes the vault current. secret init and secret vault
create call it once instead of adding the unlocker afterwards, so a
kill part-way leaves either no vault, whose temporary directory the
next command that takes the lock deletes, or a complete one. A test
records the state directory before every change the call makes and
checks each state, and the command run again from it.

Model: opus-5-5
2026-10-04 20:42:03 +02:00
clawbot f2f89c8a06 Delete the keychain item or Secure Enclave key of a failed unlocker add (closes #89)
check / check (push) Failing after 2s
A Secure Enclave unlocker add gets the long-term key before it creates
the Secure Enclave key, so a wrong passphrase creates none, and deletes
the key if a later step fails. macse.CreateKey finds the new key's hash
right after sc_auth creates it, failing with an error naming the label
if it cannot, and deletes the key if getting its public key then fails.
A keychain unlocker add writes all of the unlocker's files before it
stores the keychain item, and deletes the item if moving the unlocker
into place then fails. A failure to delete is reported along with the
original error.

The Objective-C and macse_darwin.go were only read, never compiled or
run; the new tests run only on a Mac.

Model: opus-5-5
2026-10-04 20:07:59 +02:00
clawbot 015730fb05 Delete .tmp- leftovers of a killed command when the lock is next taken (closes #75)
check / check (push) Failing after 2s
A command killed part-way could leave a temporary file or directory of
secret.WriteFileAtomic or secret.TempDirFor, encrypted keys included,
for good. LockStateDir now empties the lock file once it holds the lock
and writes "finished" there just before releasing it. A holder that
does not find that deletes such leftovers from the state directory,
each vault, each secret and each version, the only places those helpers
make them, matching names that start with "." and hold ".tmp-". After a
command that finished nothing is searched, so the added time does not
grow with the number of secrets and versions. A test shows that
`unlocker remove` removes an unlocker directory with no metadata file.

Model: opus-5-5
2026-10-04 19:25:25 +02:00
clawbot 1d7f78fd0d Add the README's required sections and clear stale TODO.md items (closes #46)
check / check (push) Failing after 2s
README gains Description, Getting Started, Rationale, Design, TODO and
License sections; its first sentence names the licence and author.
Installation and Quick Start become Getting Started; Core Architecture
becomes Design, whose two false version bullets (symlink switching,
unencrypted metadata) are corrected. README and AGENTS.md are wrapped
to prettier's settings.

TODO.md: Workflow and Next Step point at the 1.0.0 milestone and the
next branch, the old Next Step's four finished items move to Completed
Steps with their dates, and Future Steps loses the items already done.

Model: opus-5-5
2026-10-04 19:25:07 +02:00
clawbot ef79111e2e Put age identity keys into locked buffers through one function (closes #38)
check / check (push) Failing after 3s
secret.IdentityToLockedBuffer replaces the eight places that converted
an age identity's String() to bytes for a locked buffer and left the
string, which holds the private key, in ordinary memory. It moves the
string's own bytes into the buffer, which overwrites them. The copies
age makes while encoding the key remain; the function's comment says
so. TODO.md drops these places from the 1.0 memory-security entry,
along with its stale version.go reference.

Model: opus-5-5
2026-10-04 18:42:00 +02:00
clawbot 017b8d73bf Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 2s
script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
2026-10-04 18:07:56 +02:00
clawbot 1cc8653981 Ask before removing a secret, version, vault or unlocker (closes #39)
check / check (push) Failing after 2s
secret rm, secret version rm, secret vault remove and secret unlocker
remove ask [y/N] on a terminal, naming what they remove, and go ahead
only on y or yes. Without --force, a command whose stdin is not a
terminal fails at once. --force, now also on rm and version rm,
removes without asking; it replaces the old refusals to remove a vault
with secrets or the last unlocker without --force. The checks run and
the question is asked before the state directory lock is taken; under
the lock the checks run again, and nothing is removed if they would
ask a different question.

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

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

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

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

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

Model: opus-5-5
2026-10-04 14:42:01 +02:00
clawbot 007254a1f0 Check vault names in every command that takes one (closes #68)
check / check (push) Failing after 1s
A vault name may use only lowercase ASCII letters, digits, `.`, `-` and
`_`, and must not be empty, `.` or `..`; the error now states that rule.
`vault create`, `vault import`, `vault select`, `vault remove`, both
vault names of `mv` and shell completion of a `vault:secret` argument
check the name as typed before building any path from it. Before,
`vault import ..` wrote a long-term key and an unlocker into the state
directory itself, and `vault select ..` made that the current vault.

Model: opus-5-5
2026-10-04 13:58:46 +02:00
clawbot eb596b8be6 Run the checks again on every script/cibuild (closes #54)
check / check (push) Failing after 2s
On an unchanged tree docker served every check step of the Dockerfile
from its build cache, so a second script/cibuild ran no lint, tests or
build and still succeeded.

script/cibuild now passes the current time as the CHECK_EPOCH build
argument. The lint and build stages each declare it after their module
download and before `COPY . .`. A build argument whose value changes
makes every RUN step after its declaration miss the cache, so the
checks run on each build while the base images, the apk install and the
module downloads stay cached.

Model: opus-5-5
2026-10-04 13:25:24 +02:00
clawbot 596b978cb1 Leave no partial unlocker directory when adding an unlocker fails (closes #48)
check / check (push) Failing after 3s
CreatePGPUnlocker looked up the GPG key's fingerprint, and the keychain
unlocker got the long-term key, only after writing part of the unlocker,
so a failure there left a directory with no metadata. Both now do every
step that can fail before writing anything. `secret unlocker add pgp`
looks the fingerprint up once, for its duplicate check, and passes it to
CreatePGPUnlocker to record. All four unlocker types write their files
through the new secret.WriteDir, which builds a new directory in a
temporary directory, renames it into place when complete and removes it
on a failure. A directory that already exists, as when an unlocker
replaces one of the same name, is written in place and never removed.

Model: opus-5-5
2026-10-04 12:58:50 +02:00
clawbot 24d99819a3 Skip corrupt unlocker metadata in unlocker select and remove (closes #72)
check / check (push) Failing after 1s
findUnlockerByID failed on the first unlocker directory whose metadata
could not be checked, read or parsed, so `secret unlocker select` and
`remove` failed when one sorted before the unlocker asked for. It now
skips such a directory with the warning ListUnlockers gives, through
the code both now share. A skipped directory is removed by its
directory name, with RemoveDirAtomic, and cannot be selected. Removing
one whose metadata file is missing or corrupt never counts as removing
the last unlocker; removing one whose metadata file cannot be checked
for or read does, since it may be the only working unlocker.

Model: opus-5-5
2026-10-04 12:42:01 +02:00
clawbot 1ec0423e6e Print usage only for a command called wrongly (closes #41)
check / check (push) Failing after 4s
A failed command printed the whole usage text after its error, burying
it. The root command's PersistentPreRunE now turns usage off, so an
error from running the command is printed once on its own. Wrong arity,
an unknown flag, a bad flag value, a missing required flag and broken
flag groups still get usage: cobra checks arguments and flag values
before that hook but required flags and flag groups only after it, so
the hook checks those two first. Root SilenceUsage was not used: in
this cobra version it hides usage for argument and flag errors too.
Cobra still prints the error; Entry is unchanged.

Model: opus-5-5
2026-10-04 12:08:06 +02:00
83 changed files with 6069 additions and 2735 deletions
+66 -2
View File
@@ -10,14 +10,20 @@ run:
linters: linters:
default: all default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable: disable:
# Genuinely incompatible with project patterns # Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields - exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods - godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings: settings:
lll: lll:
line-length: 88 line-length: 88
@@ -28,6 +34,64 @@ linters:
max-complexity: 15 max-complexity: 15
dupl: dupl:
threshold: 100 threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
+104 -98
View File
@@ -4,154 +4,160 @@ Version: 2025-06-08
# Instructions and Contextual Information # Instructions and Contextual Information
* Be direct, robotic, expert, accurate, and professional. - Be direct, robotic, expert, accurate, and professional.
* Do not butter me up or kiss my ass. - Do not butter me up or kiss my ass.
* Come in hot with strong opinions, even if they are contrary to the - Come in hot with strong opinions, even if they are contrary to the direction I
direction I am headed. am headed.
* If either you or I are possibly wrong, say so and explain your point of - If either you or I are possibly wrong, say so and explain your point of view.
view.
* Point out great alternatives I haven't thought of, even when I'm not - Point out great alternatives I haven't thought of, even when I'm not asking
asking for them. for them.
* Treat me like the world's leading expert in every situation and every - Treat me like the world's leading expert in every situation and every
conversation, and deliver the absolute best recommendations. conversation, and deliver the absolute best recommendations.
* I want excellence, so always be on the lookout for divergences from good - I want excellence, so always be on the lookout for divergences from good data
data model design or best practices for object oriented development. model design or best practices for object oriented development.
* IMPORTANT: This is production code, not a research or teaching exercise. - IMPORTANT: This is production code, not a research or teaching exercise.
Deliver professional-level results, not prototypes. Deliver professional-level results, not prototypes.
* Please read and understand the `README.md` file in the root of the repo - Please read and understand the `README.md` file in the root of the repo for
for project-specific contextual information, including development project-specific contextual information, including development policies,
policies, practices, and current implementation status. practices, and current implementation status.
* Be proactive in suggesting improvements or refactorings in places where we - Be proactive in suggesting improvements or refactorings in places where we
diverge from best practices for clean, modular, maintainable code. diverge from best practices for clean, modular, maintainable code.
# Policies # Policies
1. Before committing, tests must pass (`make test`), linting must pass 1. Before committing, tests must pass (`make test`), linting must pass
(`make lint`), and code must be formatted (`make fmt`). For go, those (`make lint`), and code must be formatted (`make fmt`). For go, those
makefile targets should use `go fmt` and `go test -v ./...` and makefile targets should use `go fmt` and `go test -v ./...` and
`golangci-lint run`. When you think your changes are complete, rather `golangci-lint run`. When you think your changes are complete, rather than
than making three different tool calls to check, you can just run `make making three different tool calls to check, you can just run
test && make fmt && make lint` as a single tool call which will save `make test && make fmt && make lint` as a single tool call which will save
time. time.
2. Always write a `Makefile` with the default target being `test`, and with 2. Always write a `Makefile` with the default target being `test`, and with a
a `fmt` target that formats the code. The `test` target should run all `fmt` target that formats the code. The `test` target should run all tests in
tests in the project, and the `fmt` target should format the code. the project, and the `fmt` target should format the code. `test` should also
`test` should also have a prerequisite target `lint` that should run any have a prerequisite target `lint` that should run any linters that are
linters that are configured for the project. configured for the project.
3. After each completed bugfix or feature, the code must be committed. Do 3. After each completed bugfix or feature, the code must be committed. Do all of
all of the pre-commit checks (test, lint, fmt) before committing, of the pre-commit checks (test, lint, fmt) before committing, of course.
course.
4. When creating a very simple test script for testing out a new feature, 4. When creating a very simple test script for testing out a new feature,
instead of making a throwaway to be deleted after verification, write an instead of making a throwaway to be deleted after verification, write an
actual test file into the test suite. It doesn't need to be very big or actual test file into the test suite. It doesn't need to be very big or
complex, but it should be a real test that can be run. complex, but it should be a real test that can be run.
5. When you are instructed to make the tests pass, DO NOT delete tests, skip 5. When you are instructed to make the tests pass, DO NOT delete tests, skip
tests, or change the tests specifically to make them pass (unless there tests, or change the tests specifically to make them pass (unless there is a
is a bug in the test). This is cheating, and it is bad. You should only bug in the test). This is cheating, and it is bad. You should only be
be modifying the test if it is incorrect or if the test is no longer modifying the test if it is incorrect or if the test is no longer relevant.
relevant. In almost all cases, you should be fixing the code that is In almost all cases, you should be fixing the code that is being tested, or
being tested, or updating the tests to match a refactored implementation. updating the tests to match a refactored implementation.
6. When dealing with dates and times or timestamps, always use, display, and 6. When dealing with dates and times or timestamps, always use, display, and
store UTC. Set the local timezone to UTC on startup. If the user needs store UTC. Set the local timezone to UTC on startup. If the user needs to see
to see the time in a different timezone, store the user's timezone in a the time in a different timezone, store the user's timezone in a separate
separate field and convert the UTC time to the user's timezone when field and convert the UTC time to the user's timezone when displaying it. For
displaying it. For internal use and internal applications and internal use and internal applications and administrative purposes, always
administrative purposes, always display UTC. display UTC.
7. Always write tests, even if they are extremely simple and just check for 7. Always write tests, even if they are extremely simple and just check for
correct syntax (ability to compile/import). If you are writing a new correct syntax (ability to compile/import). If you are writing a new feature,
feature, write a test for it. You don't need to target complete write a test for it. You don't need to target complete coverage, but you
coverage, but you should at least test any new functionality you add. If should at least test any new functionality you add. If you are fixing a bug,
you are fixing a bug, write a test first that reproduces the bug, and write a test first that reproduces the bug, and then fix the bug in the code.
then fix the bug in the code.
8. When implementing new features, be aware of potential side-effects (such 8. When implementing new features, be aware of potential side-effects (such as
as state files on disk, data in the database, etc.) and ensure that it is state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests. possible to mock or stub these side-effects in tests.
9. Always use structured logging. Log any relevant state/context with the 9. Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output messages (but do not log secrets). If stdout is not a terminal, output the
the structured logs in jsonl format. structured logs in jsonl format.
10. Avoid using bare strings or numbers in code, especially if they appear 10. Avoid using bare strings or numbers in code, especially if they appear
anywhere more than once. Always define a constant (usually at the top anywhere more than once. Always define a constant (usually at the top of the
of the file) and give it a descriptive name, then use that constant in file) and give it a descriptive name, then use that constant in the code
the code instead of the bare string or number. instead of the bare string or number.
11. You do not need to summarize your changes in the chat after making them. 11. You do not need to summarize your changes in the chat after making them.
Making the changes and committing them is sufficient. If anything out Making the changes and committing them is sufficient. If anything out of the
of the ordinary happened, please explain it, but in the normal case ordinary happened, please explain it, but in the normal case where you found
where you found and fixed the bug, or implemented the feature, there is and fixed the bug, or implemented the feature, there is no need for the
no need for the end-of-change summary. end-of-change summary.
12. Do not create additional files in the root directory of the project 12. Do not create additional files in the root directory of the project without
without asking permission first. Configuration files, documentation, and asking permission first. Configuration files, documentation, and build files
build files are acceptable in the root, but source code and other files are acceptable in the root, but source code and other files should be
should be organized in appropriate subdirectories. organized in appropriate subdirectories.
## Python-Specific Guidelines ## Python-Specific Guidelines
1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error. 1. **Type Annotations (UP006)**: Use built-in collection types directly for type
annotations instead of importing from `typing`. This avoids the UP006 linter
**Good (modern Python 3.9+):** error.
```python
def process_items(items: list[str]) -> dict[str, int]: **Good (modern Python 3.9+):**
counts: dict[str, int] = {}
return counts ```python
``` def process_items(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
**Avoid (triggers UP006):** return counts
```python ```
from typing import List, Dict
**Avoid (triggers UP006):**
def process_items(items: List[str]) -> Dict[str, int]:
counts: Dict[str, int] = {} ```python
return counts from typing import List, Dict
```
def process_items(items: List[str]) -> Dict[str, int]:
For optional types, use the `|` operator instead of `Union`: counts: Dict[str, int] = {}
```python return counts
# Good ```
def get_value(key: str) -> str | None:
return None For optional types, use the `|` operator instead of `Union`:
# Avoid ```python
from typing import Optional, Union # Good
def get_value(key: str) -> Optional[str]: def get_value(key: str) -> str | None:
return None return None
```
# Avoid
from typing import Optional, Union
def get_value(key: str) -> Optional[str]:
return None
```
2. **Import Organization**: Follow the standard Python import order: 2. **Import Organization**: Follow the standard Python import order:
- Standard library imports - Standard library imports
- Third-party imports - Third-party imports
- Local application imports - Local application imports
Each group should be separated by a blank line. Each group should be separated by a blank line.
## Go-Specific Guidelines ## Go-Specific Guidelines
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values. 1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate
errors via return values.
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash. 2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle
errors, not crash.
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability. 3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code. 4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix
the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses. 5. **All PRs must pass `make check` with zero failures.** No exceptions, no
"pre-existing issue" excuses.
6. **Pin external dependencies by commit hash**, not mutable tags. 6. **Pin external dependencies by commit hash**, not mutable tags.
+12 -1
View File
@@ -6,11 +6,19 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
# below run again on each build, an unchanged tree included, while the
# steps above stay cached. ARG is per stage: the build stage declares it too.
ARG CHECK_EPOCH
COPY . . COPY . .
RUN make fmt-check RUN make fmt-check
# Not make lint: script/lint is a docker build, which cannot run in here. # Not make lint or make lint-darwin: script/lint and script/lint-darwin are
# docker builds, which cannot run in here. These are their commands.
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
# Build stage — tests and compilation # Build stage — tests and compilation
# golang 1.24.13-alpine (2026-03-10) # golang 1.24.13-alpine (2026-03-10)
@@ -25,6 +33,9 @@ WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# As in the lint stage: the RUN steps below run again on each script/cibuild.
ARG CHECK_EPOCH
COPY . . COPY . .
RUN make test RUN make test
+13 -3
View File
@@ -1,6 +1,6 @@
# Lint image, built by script/lint: golangci-lint runs as a build step, so a # Lint image, built by script/lint and script/lint-darwin: golangci-lint runs
# successful build is a clean lint. Works where the docker daemon is remote # as a build step, so a successful build is a clean lint. Works where the
# and bind mounts are impossible. # docker daemon is remote and bind mounts are impossible.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07 # golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
@@ -17,3 +17,13 @@ FROM deps AS lint
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN golangci-lint run --config .golangci.yml ./...
# script/lint-darwin rebuilds this stage on every run, by this name. It
# checks the code as a macOS build compiles it, but with cgo off, which
# leaves out the files that need cgo on macOS (see script/lint-darwin).
FROM deps AS lint-darwin
COPY . .
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
+6 -2
View File
@@ -1,7 +1,7 @@
export CGO_ENABLED=1 export CGO_ENABLED=1
.PHONY: default bootstrap setup build test lint fmt fmt-check check docker \ .PHONY: default bootstrap setup build test lint lint-darwin fmt fmt-check \
docker-run clean install hooks check docker docker-run clean install hooks
default: check default: check
@@ -24,6 +24,10 @@ fmt:
lint: lint:
@script/lint @script/lint
# Type-check and lint the macOS build from Linux (see script/lint-darwin)
lint-darwin:
@script/lint-darwin
check: check:
@script/check @script/check
+260 -152
View File
@@ -1,75 +1,90 @@
# secret - Local Secret Manager # secret - Local Secret Manager
secret is a command-line local secret manager that implements a hierarchical ## Description
key architecture for storing and managing sensitive data. It supports
multiple vaults, various unlock mechanisms, and provides secure storage
using the `age` encryption library.
It could be used as password manager, but was not designed as such. I `secret` is a WTFPL-licensed Go command-line local secret manager by
created it to scratch an itch for a secure key/value store for replacing a [@sneak](https://sneak.berlin) that implements a hierarchical key architecture
bunch of pgp-encrypted files in a directory structure. for storing and managing sensitive data. It supports multiple vaults, various
unlock mechanisms, and provides secure storage using the `age` encryption
library.
## Core Architecture ## Getting Started
Build from source, then install the binary as `~/bin/secret`:
```bash
git clone https://git.eeqj.de/sneak/secret.git
cd secret
make build # writes the binary to ./secret
make install # builds it and copies it to ~/bin/secret
```
Generate a mnemonic, create the default vault, then store and read a secret:
```bash
secret generate mnemonic # prints a new BIP39 mnemonic; write it down
secret init # asks for that mnemonic and an unlocker passphrase
echo "my-password" | secret add myservice/password
secret get myservice/password
```
## Rationale
I created `secret` to scratch an itch: I wanted a secure key/value store to
replace a bunch of PGP-encrypted files in a directory structure. It could be
used as a password manager, but was not designed as one.
## Design
### Three-Layer Key Hierarchy ### Three-Layer Key Hierarchy
Secret implements a three-layer key architecture: Secret implements a three-layer key architecture:
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide 1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
the foundation for all encryption foundation for all encryption
2. **Unlockers**: Short-term keys that encrypt the long-term keys, 2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
supporting multiple authentication methods multiple authentication methods
3. **Version-specific Keys**: Per-version keys that encrypt individual 3. **Version-specific Keys**: Per-version keys that encrypt individual secret
secret values values
### Version Management ### Version Management
Each secret maintains a history of versions, with each version having: Each secret maintains a history of versions, with each version having:
- Its own encryption key pair - Its own encryption key pair
- Metadata (unencrypted) including creation time and validity period - Metadata including creation time and validity period, encrypted to the
version's key pair
- Immutable value storage - Immutable value storage
- Atomic version switching via symlink updates
The secret's `current` file names its current version. Switching versions
replaces that file in one rename, so it is never half-written.
### Vault System ### Vault System
Vaults provide logical separation of secrets, each with its own long-term Vaults provide logical separation of secrets, each with its own long-term key
key and unlocker set. This allows for complete isolation between different and unlocker set. This allows for complete isolation between different contexts
contexts (work, personal, projects). (work, personal, projects).
## Installation
Build from source:
```bash
git clone <repository>
cd secret
make build
```
## Quick Start
1. **Initialize the secret manager**:
```bash
secret init
```
This creates the default vault and prompts for a BIP39 mnemonic phrase.
2. **Generate a mnemonic** (if needed):
```bash
secret generate mnemonic
```
3. **Add a secret**:
```bash
echo "my-password" | secret add myservice/password
```
4. **Retrieve a secret**:
```bash
secret get myservice/password
```
## Commands Reference ## Commands Reference
### Confirmation Before Removal
`secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
each one first asks `[y/N]`, naming exactly what it is about to remove, and goes
ahead only on `y` or `yes`; any other answer, a bare Enter included, cancels and
removes nothing. The question is asked only after the command's checks have
passed, and before it changes anything.
Whether to ask is decided by stdin, where the answer is read from, so
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
script or a CI job, nobody is there to answer: the command fails at once,
removes nothing, and says to pass `--force`.
`--force` (`-f`) removes without asking, whatever the command removes: a vault
that holds secrets and the last unlocker of a vault included. Scripts that
remove things pass `--force`.
### Initialization ### Initialization
#### `secret init` #### `secret init`
@@ -78,6 +93,7 @@ Initializes the secret manager with a default vault. Prompts for a BIP39
mnemonic phrase and creates the initial directory structure. mnemonic phrase and creates the initial directory structure.
**Environment Variables:** **Environment Variables:**
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase - `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase - `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
@@ -91,19 +107,22 @@ Lists all available vaults. The current vault is marked.
Creates a new vault with the specified name. Creates a new vault with the specified name.
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
are allowed, and a name must not be empty, `.` or `..`.
#### `secret vault select <name>` #### `secret vault select <name>`
Switches to the specified vault for subsequent operations. Switches to the specified vault for subsequent operations.
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑 #### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`, **DANGER**: Permanently removes a vault and all its secrets. It first asks for
this command does not ask for confirmation. confirmation, naming the vault and how many secrets it holds (see
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
cannot be removed. Removing the current vault makes another vault the current
one.
Requires --force if the vault contains secrets. With --force, will - `--force, -f`: Remove without asking, also a vault that contains secrets
automatically switch to another vault if removing the current one.
- `--force, -f`: Force removal even if vault contains secrets
- **NO RECOVERY**: All secrets in the vault will be permanently deleted - **NO RECOVERY**: All secrets in the vault will be permanently deleted
### Secret Management ### Secret Management
@@ -111,54 +130,66 @@ automatically switch to another vault if removing the current one.
#### `secret add <secret-name> [--force]` #### `secret add <secret-name> [--force]`
Adds a secret to the current vault. Reads the secret value from stdin. Adds a secret to the current vault. Reads the secret value from stdin.
- `--force, -f`: Overwrite existing secret - `--force, -f`: Overwrite existing secret
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` **Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
are allowed, and a name must not be empty, start with `.` or `/`, end with allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
`/`, contain `//`, or have `..` as a path segment. contain `//`, or have `..` as a path segment.
- Forward slashes (`/`) are converted to percent signs (`%`) for storage - Forward slashes (`/`) are converted to percent signs (`%`) for storage
- Examples: `database/password`, `api.key`, `ssh_private_key` - Examples: `database/password`, `api.key`, `ssh_private_key`
#### `secret get <secret-name> [--version <version>]` #### `secret get <secret-name> [--version <version>]`
Retrieves and outputs a secret value to stdout. Retrieves and outputs a secret value to stdout.
- `--version, -v`: Get a specific version (default: current) - `--version, -v`: Get a specific version (default: current)
#### `secret list [filter] [--json]` / `secret ls` #### `secret list [filter] [--json]` / `secret ls`
Lists all secrets in the current vault. Optional filter for substring Lists all secrets in the current vault. Optional filter for substring matching.
matching.
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑 #### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation. **DANGER**: Permanently removes a secret and ALL its versions. It first asks for
confirmation, naming the secret, its vault and how many versions it has (see
[Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, the secret cannot be recovered - **NO RECOVERY**: Once removed, the secret cannot be recovered
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted - **ALL VERSIONS DELETED**: Every version of the secret will be permanently
deleted
#### `secret move <source> <destination>` / `secret mv` / `secret rename` #### `secret move <source> <destination>` / `secret mv` / `secret rename`
Moves or renames a secret within the current vault. Moves or renames a secret within the current vault.
- Fails if the destination already exists - Fails if the destination already exists
- Fails if the destination is the source under another name, such as `foo` - Fails if the destination is the source under another name, such as `foo` for
for `Foo` on a case-insensitive filesystem (the macOS default); there, to `Foo` on a case-insensitive filesystem (the macOS default); there, to change
change only the case of a name, move the secret to a third name first only the case of a name, move the secret to a third name first
- Preserves all versions and metadata - Preserves all versions and metadata
### Version Management ### Version Management
#### `secret version list <secret-name>` / `secret version ls` #### `secret version list <secret-name>` / `secret version ls`
Lists all versions of a secret showing creation time, status, and validity period. Lists all versions of a secret showing creation time, status, and validity
period.
#### `secret version promote <secret-name> <version>` #### `secret version promote <secret-name> <version>`
Promotes a specific version to current by updating the symlink. Does not Promotes a specific version to current by rewriting the secret's `current` file
modify any timestamps, allowing for rollback scenarios. to name it. Does not modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑 #### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
**DANGER**: Permanently removes a specific version of a secret. Like Unix **DANGER**: Permanently removes a specific version of a secret. It first asks
`rm`, this command does not ask for confirmation. for confirmation, naming the version, the secret and its vault (see
[Confirmation Before Removal](#confirmation-before-removal)).
- `--force, -f`: Remove without asking
- **NO RECOVERY**: Once removed, this version cannot be recovered - **NO RECOVERY**: Once removed, this version cannot be recovered
- Cannot remove the current version (must promote another version first) - Cannot remove the current version (must promote another version first)
@@ -171,6 +202,7 @@ Generates a cryptographically secure BIP39 mnemonic phrase.
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]` #### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
Generates and stores a random secret. Generates and stores a random secret.
- `--length, -l`: Length of generated secret (default: 16) - `--length, -l`: Length of generated secret (default: 16)
- `--type, -t`: Type of secret (`base58`, `alnum`) - `--type, -t`: Type of secret (`base58`, `alnum`)
- `--force, -f`: Overwrite existing secret - `--force, -f`: Overwrite existing secret
@@ -179,27 +211,40 @@ Generates and stores a random secret.
#### `secret unlocker list [--json]` / `secret unlocker ls` #### `secret unlocker list [--json]` / `secret unlocker ls`
Lists all unlockers in the current vault with their metadata. Lists all unlockers in the current vault with their metadata. An unlocker's ID,
which `secret unlocker select` and `secret unlocker remove` take, is the name of
its directory in `unlockers.d`.
#### `secret unlocker add <type> [options]` #### `secret unlocker add <type> [options]`
Creates a new unlocker of the specified type: Creates a new unlocker of the specified type:
**Types:** **Types:**
- `passphrase`: Traditional passphrase-protected unlocker - `passphrase`: Traditional passphrase-protected unlocker
- `pgp`: Uses an existing GPG key for encryption/decryption - `pgp`: Uses an existing GPG key for encryption/decryption
- `keychain`: macOS Keychain integration (macOS only) - `keychain`: macOS Keychain integration (macOS only)
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only) - `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
**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. It first asks for confirmation,
does not ask for confirmation. Cannot remove the last unlocker if the vault naming the unlocker and its vault and saying whether it is the vault's last
has secrets unless --force is used. unlocker; for the last one it says how many secrets the vault holds and warns
- `--force, -f`: Force removal of last unlocker even if vault has secrets that the vault then opens only with its mnemonic (see
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
directory that `secret unlocker list` skips with a warning, because its metadata
cannot be read or parsed, is removed by the directory name the warning gives.
- `--force, -f`: Remove without asking, even the last unlocker
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase, - **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
vault data will be PERMANENTLY INACCESSIBLE vault data will be PERMANENTLY INACCESSIBLE
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means - **NO RECOVERY**: Removing all unlockers without having your mnemonic means
@@ -213,7 +258,8 @@ Selects an unlocker as the current default for operations.
#### `secret import <secret-name> --source <filename>` #### `secret import <secret-name> --source <filename>`
Imports a secret from a file and stores it in the current vault under the given name. Imports a secret from a file and stores it in the current vault under the given
name.
#### `secret vault import [vault-name]` #### `secret vault import [vault-name]`
@@ -223,7 +269,8 @@ Imports a mnemonic phrase into the specified vault (defaults to "default").
#### `secret encrypt <secret-name> [--input=file] [--output=file]` #### `secret encrypt <secret-name> [--input=file] [--output=file]`
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key. Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
generates a new Age key.
#### `secret decrypt <secret-name> [--input=file] [--output=file]` #### `secret decrypt <secret-name> [--input=file] [--output=file]`
@@ -233,13 +280,18 @@ Decrypts data using an Age key stored as a secret.
### Directory Structure ### Directory Structure
The state directory is `berlin.sneak.pkg.secret` in the user's configuration
directory: on Linux `$XDG_CONFIG_HOME`, or `~/.config` when that is unset; on
macOS `~/Library/Application Support`. When `SB_SECRET_STATE_DIR` is set, it is
the state directory instead. On Linux:
``` ```
~/.local/share/secret/ ~/.config/berlin.sneak.pkg.secret/
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlockers.d/ │ │ ├── unlockers.d/
│ │ │ ├── passphrase/ # Passphrase unlocker │ │ │ ├── passphrase-<time>/ # Passphrase unlocker
│ │ │ └── pgp/ # PGP unlocker │ │ │ └── <host>-pgp-<time>/ # PGP unlocker
│ │ ├── secrets.d/ │ │ ├── secrets.d/
│ │ │ ├── api%key/ # Secret: api/key │ │ │ ├── api%key/ # Secret: api/key
│ │ │ │ ├── versions/ │ │ │ │ ├── versions/
@@ -247,64 +299,76 @@ Decrypts data using an Age key stored as a secret.
│ │ │ │ │ │ ├── pub.age # Version public key │ │ │ │ │ │ ├── pub.age # Version public key
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted) │ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
│ │ │ │ │ │ ├── value.age # Encrypted value │ │ │ │ │ │ ├── value.age # Encrypted value
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata │ │ │ │ │ │ └── metadata.age # Encrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version │ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current -> versions/20231216.001 │ │ │ │ └── current # Current version's name: 20231216.001
│ │ │ └── database%password/ # Secret: database/password │ │ │ └── database%password/ # Secret: database/password
│ │ │ ├── versions/ │ │ │ ├── versions/
│ │ │ └── current -> versions/20231215.001 │ │ │ └── current # Current version's name: 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 -> ../unlockers.d/passphrase │ │ └── current-unlocker # Current unlocker's directory name
│ └── work/ │ └── work/
│ ├── unlockers.d/ │ ├── unlockers.d/
│ ├── secrets.d/ │ ├── secrets.d/
│ ├── vault-metadata.json │ ├── vault-metadata.json
│ ├── pub.age │ ├── pub.age
│ └── current-unlocker │ └── current-unlocker
└── currentvault -> vaults.d/default ├── currentvault # Current vault's name: default
└── lock # Locked by each command that changes anything
``` ```
`current`, `currentvault` and `current-unlocker` are plain files that each hold
one name. Changing one replaces it in one rename, so it is never half-written.
### Key Management and Encryption Flow ### Key Management and Encryption Flow
#### 1: Long-term Keys #### 1: Long-term Keys
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical
deterministic (HD) key derivation
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys - **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers - **Storage**: Public key stored as `pub.age`, private key encrypted by
unlockers
#### 2: Unlockers #### 2: Unlockers
Unlockers provide different authentication methods to access the long-term keys: Unlockers provide different authentication methods to access the long-term keys:
1. **Passphrase Unlockers**: 1. **Passphrase Unlockers**:
- Encrypted with user-provided passphrase - Encrypted with user-provided passphrase
- Stored as encrypted Age keys - Stored as encrypted Age keys
- Cross-platform compatible - Cross-platform compatible
2. **PGP Unlockers**: 2. **PGP Unlockers**:
- Uses existing GPG key infrastructure - Uses existing GPG key infrastructure
- Leverages existing key management workflows - Leverages existing key management workflows
- Strong authentication through GPG - Strong authentication through GPG
3. **Keychain Unlockers** (macOS only): 3. **Keychain Unlockers** (macOS only):
- Stores unlock keys in macOS Keychain - Stores unlock keys in macOS Keychain
- Protected by system authentication (Touch ID, password) - Kept on this Mac only: the keychain item is never synced to other devices
- Automatic unlocking when Keychain is unlocked - Automatic unlocking when Keychain is unlocked
- Cross-application integration - Cross-application integration
4. **Secure Enclave Unlockers** (macOS): 4. **Secure Enclave Unlockers** (macOS):
- Hardware-backed key storage using Apple Secure Enclave - Hardware-backed key storage using Apple Secure Enclave
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required) - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
- ECIES encryption: vault long-term key encrypted directly by SE hardware Program required)
- Protected by biometric authentication (Touch ID) or system password - ECIES encryption: the vault long-term key is encrypted directly to the SE
key, and only the SE can decrypt it
- The SE key cannot leave this Mac; using it asks for no Touch ID or
password
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets. Each vault maintains its own set of unlockers and one long-term key. The
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
access vault secrets.
#### 3: Secret-specific Keys #### 3: Secret-specific Keys
- Each secret version has its own encryption key pair - Each secret version has its own encryption key pair
- Private key encrypted to the vault's long-term key - Private key encrypted to the vault's long-term key
- Provides forward secrecy and granular access control - A version's private key decrypts only that version's value and metadata
### Environment Variables ### Environment Variables
@@ -313,11 +377,24 @@ 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
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys - Uses the [age encryption library](https://age-encryption.org/) with X25519
keys
- All private keys are encrypted at rest - All private keys are encrypted at rest
- No plaintext secrets stored on disk - No plaintext secrets stored on disk
@@ -336,7 +413,8 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
- Hardware token support via PGP/GPG integration - Hardware token support via PGP/GPG integration
- macOS Keychain integration for system-level security - macOS Keychain integration for system-level security
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit) - Secure Enclave integration for hardware-backed key protection (macOS, via
`sc_auth` / CryptoTokenKit)
## Examples ## Examples
@@ -357,7 +435,7 @@ secret list
secret get database/prod/password secret get database/prod/password
secret get services/api/key secret get services/api/key
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!) # Remove a secret ⚠️ 🛑 (asks first - PERMANENT!)
secret remove ssh/servers/web01 secret remove ssh/servers/web01
``` ```
@@ -380,11 +458,12 @@ echo "personal-email-pass" | secret add email/password
# List all vaults # List all vaults
secret vault list secret vault list
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!) # Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!)
secret vault remove personal --force secret vault remove personal --force
``` ```
### Advanced Authentication ### Advanced Authentication
```bash ```bash
# Add multiple unlock methods # Add multiple unlock methods
secret unlocker add passphrase # Password-based secret unlocker add passphrase # Password-based
@@ -398,7 +477,7 @@ secret unlocker list
# Select a specific unlocker # Select a specific unlocker
secret unlocker select <unlocker-id> secret unlocker select <unlocker-id>
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!) # Remove an unlocker ⚠️ 🛑 (asks first!)
secret unlocker remove <unlocker-id> secret unlocker remove <unlocker-id>
``` ```
@@ -411,7 +490,7 @@ secret version list database/prod/password
# Promote an older version to current # Promote an older version to current
secret version promote database/prod/password 20231215.001 secret version promote database/prod/password 20231215.001
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!) # Remove an old version ⚠️ 🛑 (asks first - PERMANENT!)
secret version remove database/prod/password 20231214.001 secret version remove database/prod/password 20231214.001
``` ```
@@ -431,27 +510,41 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
## Technical Details ## Technical Details
### Cryptographic Primitives ### Cryptographic Primitives
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation - **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
- **Encryption**: Age (X25519 + ChaCha20-Poly1305) - **Encryption**: Age (X25519 + ChaCha20-Poly1305)
- **Authentication**: Poly1305 MAC - **Authentication**: Poly1305 MAC
- **Hashing**: Double SHA-256 for public key identification - **Hashing**: Double SHA-256 for public key identification
### File Formats ### File Formats
- **age Files**: Standard age encryption format (.age extension)
- **Metadata**: Unencrypted JSON format with timestamps and type information - **age Files**: Standard age encryption format (.age extension), except
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash `pub.age`, which holds an age public key as text
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
unencrypted JSON with a creation time, and `unlocker-metadata.json` also
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
the version's public key
- **Vault Metadata**: JSON containing creation time, derivation index, and the
public key hashes described below
### Vault Management ### Vault Management
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair - **Derivation Index**: Each vault uses a unique derivation index from the
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic mnemonic, and thus a unique key pair
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived - **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
hash of the index-0 public key identifies vaults from the same mnemonic
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
automatically derived
### Cross-Platform Support ### Cross-Platform Support
- **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
@@ -477,6 +570,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
## Development ## Development
### Building ### Building
```bash ```bash
make build # Build binary make build # Build binary
make test # Run tests make test # Run tests
@@ -484,11 +578,11 @@ make lint # Run linter
``` ```
### Testing ### Testing
The project includes comprehensive tests: The project includes comprehensive tests:
```bash ```bash
make test # Run all tests make test # Run all tests
go test ./... # Unit tests
go test -tags=integration -v ./internal/cli # Integration tests
``` ```
## Entrypoints ## Entrypoints
@@ -496,54 +590,68 @@ go test -tags=integration -v ./internal/cli # Integration tests
This repository adheres to the This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call development workflow, and the Makefile targets are thin shims that call them. We
them. We provide: provide:
- `script/bootstrap` — install all dependencies (Go, Go module - `script/bootstrap` — install all dependencies (Go, Go module download),
download), idempotently; golangci-lint is not installed, it runs in idempotently; golangci-lint is not installed, it runs in docker
docker
- `script/setup` — make a fresh clone ready for development: runs - `script/setup` — make a fresh clone ready for development: runs
`script/bootstrap`, then `script/install-precommit` `script/bootstrap`, then `script/install-precommit`
- `script/projectname` — output the project name (`secret`); used by - `script/projectname` — output the project name (`secret`); used by other
other scripts such as `script/docker` scripts such as `script/docker`
- `script/build` — build the `secret` binary into the repo root, stamping - `script/build` — build the `secret` binary into the repo root, stamping the
the version (`VERSION` from the environment, else `git describe`) and version (`VERSION` from the environment, else `git describe`) and the git
the git commit commit
- `script/test` — run `go vet` and the test suite (verbose rerun on - `script/test` — run `go vet` and the test suite (verbose rerun on failure)
failure) - `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`,
- `script/lint` — run `golangci-lint` in docker only: builds where the linter is a build step that runs on every call, also on an unchanged
`Dockerfile.lint`, where the linter is a build step that runs on every tree
call, also on an unchanged tree - `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never
compiles; cgo is off, so the keychain unlocker's calls into the keychain
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
and the Secure Enclave bindings (`internal/macse`) are not checked
- `script/fmt` — format all Go code (writes) - `script/fmt` — 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`, and - `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
`script/fmt-check` `script/fmt-check`
- `script/docker` — build the Docker image tagged with the project name - `script/docker` — build the Docker image tagged with the project name
- `script/cibuild` — CI entrypoint: `docker build --ulimit - `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .`
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the (memguard needs mlock; the Dockerfile runs the checks), with a new
checks) `CHECK_EPOCH` build argument on every run so the checks run again on an
- `script/precommit` — pre-commit checks: `go mod tidy` verification, unchanged tree
then `script/check` - `script/precommit` — pre-commit checks: `go mod tidy` verification, then
- `script/install-precommit` — install the git pre-commit hook that `script/check`
runs `script/precommit` - `script/install-precommit` — install the git pre-commit hook that runs
`script/precommit`
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers - **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain,
and Secure Enclave unlockers
- **Vault Isolation**: Complete separation between different vaults - **Vault Isolation**: Complete separation between different vaults
- **Per-Secret Encryption**: Each secret has its own encryption key - **Per-Secret Encryption**: Each secret has its own encryption key
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases - **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems - **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
# Author ## TODO
Made with love and lots of expensive SOTA AI by Open work is tracked on the
[sneak](https://sneak.berlin) in Berlin in the summer of 2025. [issue tracker](https://git.eeqj.de/sneak/secret/issues), which is
authoritative. The work to be done before 1.0 is the
[`1.0.0` milestone](https://git.eeqj.de/sneak/secret/milestone/12). `TODO.md`
records the steps completed so far.
Released as a free software gift to the world, no strings attached, under ## License
the [WTFPL](https://www.wtfpl.net/) license.
Released as a free software gift to the world, no strings attached, under the
[WTFPL](https://www.wtfpl.net/) license; see [`LICENSE`](LICENSE).
## Author
Made with love and lots of expensive SOTA AI by [@sneak](https://sneak.berlin)
in Berlin in the summer of 2025.
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin) Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2) [https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
+257 -72
View File
@@ -1,30 +1,249 @@
# Workflow # Workflow
* branch (from `main`) - branch from `next`
* do the work in Next Step - do the Next Step: the next open issue in the `1.0.0` milestone
* move Next Step to the top of Completed Steps - log it at the top of Completed Steps
* move the top item of Future Steps into Next Step - commit (`TODO.md` changes in the same commit as the work)
* commit (`TODO.md` changes in the same commit as the work) - push, and open a PR against `next`
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status # Status
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06). authoritative.
# Next Step # Next Step
Bring the repo into policy compliance in one commit: Take the next open issue in the `1.0.0` milestone:
https://git.eeqj.de/sneak/secret/milestone/12
- Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/
docker already exist).
- Add REPO_POLICIES.md and .editorconfig.
- Add .gitea/workflows/check.yml running make check.
- Verify Dockerfile base images are pinned by sha256.
# Completed Steps # Completed Steps
- 2026-10-04: When a vault cannot be opened through its current unlocker,
because a file the unlocker needs is missing or damaged, its keychain item
or Secure Enclave key is gone, or the passphrase is wrong, the error now
ends by naming the vault, saying that it still opens with its mnemonic,
and that `secret unlocker add passphrase`, run with `SB_SECRET_MNEMONIC`
set to it, gives the vault a new unlocker; for a vault that is not the
current one, as in `secret move` between vaults, it says to run
`secret vault select` first (https://git.eeqj.de/sneak/secret/issues/47).
Before, it ended with the bare cause. The advice is given only when the
vault metadata records the key the mnemonic derives, so not for a vault
created without a mnemonic, and not when the passphrase could not be read
at all. `secret vault import` is not named: it refuses a vault that has a
long-term key. `secret encrypt` and `secret decrypt` now read the key
secret through `vault.GetSecret`, as `secret get` does, so they give the
same advice; `Secret.GetValue`, the other way to get the long-term key, is
removed. When a secret's `current` file cannot be read, the error says
that `secret version list` lists its versions and `secret version promote`
makes one current. The causes stay wrapped.
- 2026-10-04: An unlocker's ID is the name of its directory in `unlockers.d`,
so no two unlockers of a vault share one
(https://git.eeqj.de/sneak/secret/issues/98). Before, a keychain or Secure
Enclave unlocker's ID was its creation time to the minute and the host name,
and a passphrase unlocker's the time to the minute, so two created within a
minute shared an ID, and `unlocker select`, `unlocker remove` and the
selection `unlocker add` makes acted on the older one. A PGP unlocker's ID
was `pgp-` and its key's fingerprint; a second PGP unlocker for a key is
still refused, now by comparing the fingerprint in the other unlockers'
metadata. `unlocker list` and the shell completion of `unlocker select` and
`unlocker remove` take each ID from the directory the unlocker was read
from, no longer by matching metadata, so two unlockers with the same
metadata are listed apart; an unlocker of an unknown type is listed under
its directory name, and completion now offers Secure Enclave unlockers too.
The keychain and Secure Enclave code was type-checked by
`script/lint-darwin`, never run; a test on Linux lists, completes, selects
and removes each of two passphrase unlockers with the same metadata by its
own ID.
- 2026-10-04: README's Storage Architecture, `secret version promote`,
Technical Details and Testing text matches the code
(https://git.eeqj.de/sneak/secret/issues/102). `current` and
`currentvault` are plain files holding a name, not symbolic links; a
version's metadata is the encrypted `metadata.age`; the state directory is
`berlin.sneak.pkg.secret` in the user's configuration directory, not
`~/.local/share/secret`, and holds the `lock` file. Also corrected: the
code sets up no Touch ID for the keychain or Secure Enclave unlocker, and
the Secure Enclave only decrypts; per-version keys give no forward
secrecy; `pub.age` is not age-encrypted; vault metadata holds no vault
name. Testing lists only `make test`.
- 2026-10-04: `secret init` and `secret vault create` create a vault whole or
not at all (https://git.eeqj.de/sneak/secret/issues/105).
`vault.CreateVault` now takes the unlocker passphrase too, writes the vault
directory with its metadata, long-term public key and passphrase unlocker,
`longterm.age` included, into a temporary directory, renames that into
`vaults.d` once it is complete, and only then makes the vault current.
Before, either command killed after the passphrase prompt but before the
unlocker was written left a vault with no unlocker, which `vault create` had
already made current and which neither command would create again. Killed
part-way now, it leaves no vault, and the next command that takes the lock
deletes the temporary directory; or, killed between the rename and making
the vault current, a complete vault that is not current, which
`secret vault select` makes current.
- 2026-10-04: A failed `secret unlocker add keychain` or
`secret unlocker add secure-enclave` no longer leaves its keychain item or
Secure Enclave key behind (https://git.eeqj.de/sneak/secret/issues/89).
`CreateSecureEnclaveUnlocker` gets the long-term key before it creates the
Secure Enclave key, so that a wrong passphrase creates none, and deletes the
key again if encrypting with it or writing the unlocker then fails.
`macse.CreateKey` finds the new key's hash right after `sc_auth` creates
it, and fails with an error naming the key's label if it cannot; it deletes
the key again if getting its public key then fails. The Objective-C was only
read, never compiled or run, and so was `macse_darwin.go`, which is cgo only.
`CreateKeychainUnlocker` writes all of the unlocker's files, the metadata
among them, before it stores the item in the keychain, and deletes the item
again if moving the unlocker into place then fails. A failure to delete is
reported along with the first error. The tests of this run only on macOS:
the Secure Enclave one in a build with cgo on a Mac with a Secure Enclave,
the keychain one in a build with cgo.
- 2026-10-04: What a command killed part-way left under a `.tmp-` name
(https://git.eeqj.de/sneak/secret/issues/75), the temporary directories
of `secret.TempDirFor` and the temporary files of
`secret.WriteFileAtomic`, encrypted keys included, is deleted by the next
command that takes the state directory lock. Before, it stayed until
deleted by hand. A command writes `finished` into the lock file just
before it releases the lock; the next one to take the lock searches only
when it does not find that, so after a command that finished nothing is
searched, however many secrets and versions there are. The search looks
in the state directory, each vault, each secret and each version, the
only directories those helpers make them in. A command that only reads
takes no lock and deletes nothing. A failure to delete is warned about
and the command goes on. An unlocker directory with no metadata file was
already removed by `secret unlocker remove` given its directory name; a
test now shows it.
- 2026-10-04: An age identity's private key goes into a locked buffer
through `secret.IdentityToLockedBuffer` everywhere
(https://git.eeqj.de/sneak/secret/issues/38): the vault's long-term key
when a passphrase, PGP, keychain or Secure Enclave unlocker is created,
the new unlocker's own key, a new secret version's key, and the key
`secret encrypt` generates. Before, each place converted the string age
returns to bytes and left the string in ordinary memory. The function
moves the string's own bytes into the buffer, which overwrites them; the
copies age makes while writing the string remain, as its comment says.
The 1.0 memory-security entry below no longer lists these places,
`internal/cli/crypto.go` among them, nor `version.go:155`, which was
`internal/secret/version.go`, not `internal/cli/version.go`.
- 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
Before, CI on Linux never compiled the files built only for macOS. Compiling
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions
that call `go-keychain` moved from `keychainunlocker.go` to
`keychainunlocker_cgo.go`, built only with cgo on macOS like
`macse_darwin.go`. A macOS build without cgo, which before did not compile,
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
say the keychain or Secure Enclave needs a macOS build with cgo. The check
covers the rest of the keychain unlocker, the Secure Enclave unlocker and
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
into functions of their own, and the Secure Enclave unlocker derives the
long-term key from the mnemonic through the same function as the keychain
unlocker instead of a copy of it. Lines over 88 columns in the files the
check cannot see are wrapped.
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
`secret unlocker remove` ask `[y/N]` before removing anything
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
secret, its vault and its version count; the version, secret and vault; the
vault and its secret count; the unlocker, its vault and whether it is the
last, and for the last the vault's secret count and that the vault then
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without
`--force`, a command whose stdin is not a terminal fails at once. `--force`
(now also on `rm` and `version rm`) removes without asking; it replaces the
old refusals to remove a vault with secrets or the last unlocker of one
without `--force`, which the question now covers. The checks run, and the
question is asked, before the state directory lock is taken; under the
lock the checks run again, and if they would ask a different question,
nothing is removed. `secret rm` fails when it cannot count the versions.
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
Enclave unlocker the keychain item or Secure Enclave key, which names the
directory, carries the time instead of the day. `secret.WriteDir` fails on a
directory that exists instead of writing into it. `unlocker add passphrase`
writes the new unlocker, makes it current, and only then removes the vault's
other passphrase unlockers; a crash between the last two steps leaves the old
one beside the new, and the old passphrase still opens the vault through it
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
another of its type is added beside it instead of replacing it.
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
unset at once, so that no program the command runs, `gpg` included,
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
the command reads the environment; the buffers are passed down:
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
constructors take both. `CreatePGPUnlocker` sets both on the vault it
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
`vault create` no longer put the mnemonic into the environment. Unsetting
erases nothing: the starting environment (`/proc/<pid>/environ`) and
memory still hold the value. The README warns against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from
`sneak/prompts`, byte for byte
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
in place of the deprecated `gomodguard`, so the lint no longer warns,
and enables `depguard` with a rule that keeps `net/http/httptest` out of
non-test files. Neither raised a finding in this repo.
- 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
the vault's long-term key as adding a passphrase unlocker does, with the
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
mnemonic, checked against the vault, or else from the current unlocker.
Before, it used the keychain unlocker's helper, which on every platform
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
key, getting the long-term key once from the mnemonic and once from a
passphrase unlocker, and reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
`.`, `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
state the rule. `vault create`, `vault import`, `vault select`,
`vault remove`, both vault names of `mv` and shell completion of a
`vault:secret` argument check the name as typed with
`vault.ValidateVaultName` before building any path from it. Before,
`vault import ..` wrote a long-term key and an unlocker into the state
directory itself, and `vault select ..` made that the current vault.
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
current time as the `CHECK_EPOCH` build argument, which both the lint
and the build stage of the `Dockerfile` declare after their module
download, so the `RUN` steps below the argument run again on each
build while the base images and module downloads stay cached. Before,
a second run on the same tree took every check from the build cache
and reported success having run nothing.
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for
its duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
and encrypt everything before writing anything. All four unlocker
types write their files through `secret.WriteDir`: a new unlocker is
built in a temporary directory, renamed into place when complete and
removed on a failure.
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
skip, with the warning `unlocker list` gives, an unlocker directory
whose metadata file cannot be checked for, read or parsed, instead of
failing when it sorts before the unlocker asked for. Such a directory,
or one without a metadata file, is removed by its directory name, the
name the warning gives; only the directory is removed, since its type
is unknown. Removing one whose metadata file is missing or corrupt
never counts as removing the last unlocker. Removing one whose metadata
file cannot be checked for or read always does, since it may be the
only working unlocker, so in a vault with secrets it needs `--force`.
- 2026-10-04: A failed command prints its error once, without the usage
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
still printed for a command called wrongly: wrong number of arguments,
unknown flag, bad flag value, missing required flag, or flags that
break a flag group (mutually exclusive, required together, one
required). The root command's `PersistentPreRunE` turns usage off.
Cobra checks arguments and flag values before that hook but required
flags and flag groups only after it, so the hook checks those two
first. Root `SilenceUsage` would have hidden usage for all of these.
- 2026-10-04: `secret get` keeps the secret in locked memory until it - 2026-10-04: `secret get` keeps the secret in locked memory until it
writes it out (https://git.eeqj.de/sneak/secret/issues/37): writes it out (https://git.eeqj.de/sneak/secret/issues/37):
`Vault.GetSecret` and `Vault.GetSecretVersion` return a `Vault.GetSecret` and `Vault.GetSecretVersion` return a
@@ -99,25 +318,7 @@ Bring the repo into policy compliance in one commit:
`current-unlocker` never go missing. New versions, new secrets and `current-unlocker` never go missing. New versions, new secrets and
cross-vault copies are built in a temporary directory and renamed cross-vault copies are built in a temporary directory and renamed
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.
interrupted command can still leave:
- a broken unlocker, when it was replacing one: an unlocker added
under the directory name of an existing one is rewritten file by
file. That happens to a passphrase unlocker added to a vault that
has one, and to a PGP, keychain or Secure Enclave unlocker added
on the same host and day as another of its type
(https://git.eeqj.de/sneak/secret/issues/71);
- from `init` or `vault create` killed after the passphrase prompt
but before the unlocker is written, a vault with no unlocker,
which `vault create` has already made the current vault;
- from an unlocker add stopped before its metadata is written, a
directory that `unlocker list` warns about and `unlocker rm`
cannot remove;
- data under a `.tmp-` name in the state directory: a secret or
version being added, or the secret, version, unlocker or vault
being removed, encrypted keys included. Nothing deletes it; it
must be deleted by hand
(https://git.eeqj.de/sneak/secret/issues/75).
- 2026-10-03: The checks run before changing a vault now stop with an - 2026-10-03: The checks run before changing a vault now stop with an
error naming the path and cause when they cannot read what they error naming the path and cause when they cannot read what they
inspect, instead of reading the failure as "nothing there": the inspect, instead of reading the failure as "nothing there": the
@@ -173,8 +374,15 @@ Bring the repo into policy compliance in one commit:
`findUnlockerIDByMetadata` now returns an error so `unlocker list` `findUnlockerIDByMetadata` now returns an error so `unlocker list`
skips an unreadable `unlockers.d` entry with a warning instead of skips an unreadable `unlockers.d` entry with a warning instead of
emitting a fabricated fallback ID. emitting a fabricated fallback ID.
- 2026-08-07: Added `.editorconfig`
(https://git.eeqj.de/sneak/secret/issues/27).
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
`.gitea/workflows/check.yml` now runs `script/cibuild`.
- 2026-03-30: Added the `make fmt-check` target and
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
`Dockerfile` base images are pinned by sha256.
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret - 2026-03-11: Secure Enclave unlocker for hardware-backed secret
protection, plus review fixes (stub panics, derivation index, tests, protection, plus review fixes (stub panics, derivation index, tests,
README) on branch secure-enclave-unlocker. README) on branch secure-enclave-unlocker.
@@ -196,53 +404,30 @@ Bring the repo into policy compliance in one commit:
# Future Steps # Future Steps
- Compliance (after Next Step lands): keep main green under the new
.gitea workflow; run make check before every merge.
- Implement version-number shell completion for the second arg of - Implement version-number shell completion for the second arg of
`secret version promote` and `secret version rm` `secret version promote` and `secret version rm`
(`internal/cli/version.go`; was an in-code TODO removed for godox). (`internal/cli/version.go`; was an in-code TODO removed for godox).
- Cover mnemonic-vs-xprv identity consistency in - Cover mnemonic-vs-xprv identity consistency in
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an `pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
in-code FIXME removed for godox). in-code FIXME removed for godox).
- Darwin-gated files (`internal/secret/keychainunlocker.go`, - CI does not compile, lint or test the files built only with cgo on
`seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related macOS, since compiling them needs Apple's SDK:
tests) are not linted on the Linux CI runner and still contain lines `internal/secret/keychainunlocker_cgo.go` (the three functions that call
over the new 88-column limit; they will surface if lint ever runs on `go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
macOS. (`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
- Merge secure-enclave-unlocker to main once review is done. never run on them, so it would likely find more there than the line
lengths. No macOS test runs in CI. A macOS runner would cover all of it
(asked on https://git.eeqj.de/sneak/secret/issues/50).
- 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 - Memory security: age writes an identity's private key out as a string in
(pgpunlocker.go:323-327); data.String() passed unescaped to the ordinary memory, and the copies it makes on the way stay there
security command (keychainunlocker.go:472-476). (`secret.IdentityToLockedBuffer` overwrites only the string itself).
- Memory security: age identity .String() creates unprotected
copies (keychainunlocker.go:356, pgpunlocker.go:256,
version.go:155); age secret key held in a plain string in
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
to GPGEncryptFunc and EncryptWithPassphrase.
- Input validation: no maximum secret size (DoS).
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
209-216); non-constant-time public key compare (vault.go:95-100).
- High priority:
- Secure temporary file handling and cleanup.
- Print cobra usage only for argument errors, not internal
failures.
- Initialize a default unlock key at vault creation.
- Confirmation prompts for destructive operations (keys rm, vault
deletion).
- Add secret rm and vault deletion commands.
- Medium priority: - Medium priority:
- Standardize error messages; stop leaking internals. - Standardize error messages; stop leaking internals.
- Graceful handling of corrupted or missing key files with recovery
suggestions.
- 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.
- Cleanups: read statedir from environment or default instead of - Cleanups: read statedir from environment or default instead of
passing it around. passing it around.
- Enhancements: help examples, shell completion, colored output, - Enhancements: help examples, colored output, --quiet flag, name suggestions on
--quiet flag, name suggestions on miss, audit logging, hardware miss, audit logging, hardware integration tests (Keychain, GPG), naming
integration tests (Keychain, GPG), naming consistency, vault consistency, vault export/import, batch operations, search, secret metadata
export/import, batch operations, search, secret metadata
(descriptions, tags). (descriptions, tags).
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/btcsuite/btcd/btcec/v2 v2.1.3 github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
github.com/creack/pty v1.1.24
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1 github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
github.com/oklog/ulid/v2 v2.1.1 github.com/oklog/ulid/v2 v2.1.1
github.com/spf13/afero v1.14.0 github.com/spf13/afero v1.14.0
+2
View File
@@ -35,6 +35,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+52
View File
@@ -3,8 +3,11 @@ package cli
import ( import (
"fmt" "fmt"
"io"
"os"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -14,6 +17,15 @@ 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
// terminal, when set, stands in for the terminal that confirm reads
// the user's answer from; only tests set it. When it is nil, confirm
// reads stdin, and only when stdin is a terminal.
terminal io.Reader
} }
// NewCLIInstance creates a new CLI instance with the real filesystem // NewCLIInstance creates a new CLI instance with the real filesystem
@@ -68,3 +80,43 @@ func (cli *Instance) SetStateDir(stateDir string) {
func (cli *Instance) GetStateDir() string { func (cli *Instance) GetStateDir() string {
return cli.stateDir return cli.stateDir
} }
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
// either calls it once, before anything else, and passes the buffers on
// from there: each variable is unset as soon as it is read, so that the
// processes this one starts, gpg among them, do not inherit it, and a
// second read would find nothing. The returned function destroys both
// buffers.
func (cli *Instance) readSecretEnv() func() {
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
return func() {
if mnemonic != nil {
mnemonic.Destroy()
}
if passphrase != nil {
passphrase.Destroy()
}
}
}
// readAndUnsetEnv returns the value of the environment variable name in a
// locked buffer, or nil when it is unset or empty, and unsets the variable.
// Unsetting does not erase the value: it stays in this process's memory,
// and in /proc/<pid>/environ, which shows the environment the process
// started with. The caller must destroy the returned buffer.
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
value := os.Getenv(name)
_ = os.Unsetenv(name)
if value == "" {
return nil
}
return memguard.NewBufferFromBytes([]byte(value))
}
+13 -30
View File
@@ -1,10 +1,10 @@
package cli package cli
import ( import (
"path/filepath" "maps"
"slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
} }
// getUnlockerIDsCompletionFunc returns a completion function that provides // getUnlockerIDsCompletionFunc returns a completion function that provides
// unlocker IDs // unlocker IDs, the names of the unlockers' directories in unlockers.d
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func( func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
cmd *cobra.Command, args []string, toComplete string, cmd *cobra.Command, args []string, toComplete string,
) ([]string, cobra.ShellCompDirective) { ) ([]string, cobra.ShellCompDirective) {
@@ -57,38 +57,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
} }
// Get unlocker metadata list unlockerMetadata, err := vlt.ListUnlockers()
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
} }
// Get vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
// Collect unlocker IDs
var completions []string var completions []string
unlockersDir := filepath.Join(vaultDir, "unlockers.d") for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
if strings.HasPrefix(id, toComplete) {
for _, metadata := range unlockerMetadataList {
// Get the actual unlocker ID by creating the unlocker instance
id, err := findUnlockerIDByMetadata(
fs, unlockersDir, metadata, false,
)
if err != nil {
secret.Warn(
"Could not read unlockers directory during completion, "+
"skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
if id != "" && strings.HasPrefix(id, toComplete) {
completions = append(completions, id) completions = append(completions, id)
} }
} }
@@ -123,7 +100,9 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
} }
// completeVaultQualifiedSecrets completes "vault:secret" references once a // completeVaultQualifiedSecrets completes "vault:secret" references once a
// colon is present in the input // colon is present in the input. It completes nothing when the vault part
// is not a valid vault name, so that a name such as ".." cannot list a
// directory outside vaults.d.
func completeVaultQualifiedSecrets( func completeVaultQualifiedSecrets(
fs afero.Fs, stateDir, toComplete string, fs afero.Fs, stateDir, toComplete string,
) []string { ) []string {
@@ -134,6 +113,10 @@ func completeVaultQualifiedSecrets(
vaultName := parts[0] vaultName := parts[0]
secretPrefix := parts[1] secretPrefix := parts[1]
if vault.ValidateVaultName(vaultName) != nil {
return nil
}
vlt := vault.NewVault(fs, stateDir, vaultName) vlt := vault.NewVault(fs, stateDir, vaultName)
secrets, err := vlt.ListSecrets() secrets, err := vlt.ListSecrets()
+41
View File
@@ -0,0 +1,41 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestVaultSecretCompletionRejectsInvalidVaultName is a regression test for
// https://git.eeqj.de/sneak/secret/issues/68: completing a `vault:secret`
// argument lists nothing when the vault part is not a valid vault name, even
// where that name, joined onto vaults.d, leads to a secrets.d directory.
func TestVaultSecretCompletionRejectsInvalidVaultName(t *testing.T) {
t.Parallel()
const (
stateDir = "/state"
dirPerm = 0o700
)
fs := afero.NewMemMapFs()
// The vault "work" holds the secret "x". So does every directory an
// invalid name below would lead to from vaults.d.
for _, vaultName := range []string{"work", ".", "..", "a/b"} {
secretDir := filepath.Join(stateDir, "vaults.d", vaultName, "secrets.d", "x")
require.NoError(t, fs.MkdirAll(secretDir, dirPerm))
}
assert.Equal(t, []string{"work:x"},
completeVaultQualifiedSecrets(fs, stateDir, "work:"))
for _, toComplete := range []string{".:", "..:", "a/b:"} {
assert.Empty(t, completeVaultQualifiedSecrets(fs, stateDir, toComplete),
"completing %q", toComplete)
}
}
+108
View File
@@ -0,0 +1,108 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/cobra"
"golang.org/x/term"
)
// Sentinel errors for asking the user to confirm a removal
var (
errNoTerminal = errors.New("stdin is not a terminal, so there is " +
"nobody to ask for confirmation; pass --force to remove without asking")
errNotConfirmed = errors.New("cancelled; nothing was removed")
errChangedWhileAsking = errors.New("what was to be removed changed " +
"while waiting for the answer; nothing was removed")
)
// askThenLock asks the user to confirm a removal, unless force is set, and
// then takes the state directory lock and returns the function that
// releases it. find makes the command's checks, keeps what it found for
// the caller to remove, and returns the question that names it. find runs
// before the question, which is asked without the lock so that no other
// command waits while the user answers, and runs again once the lock is
// taken. That run is the last, so the caller removes what find found under
// the lock. If its question then differs from the one the user answered,
// something changed in between, and askThenLock fails.
func (cli *Instance) askThenLock(
cmd *cobra.Command, force bool, find func() (string, error),
) (func(), error) {
asked := ""
if !force {
question, err := find()
if err != nil {
return nil, err
}
err = cli.confirm(cmd, question)
if err != nil {
return nil, err
}
asked = question
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil {
return nil, err
}
question, err := find()
if err == nil && !force && question != asked {
err = errChangedWhileAsking
}
if err != nil {
release()
return nil, err
}
return release, nil
}
// confirm asks question and returns nil only when the user answers y or
// yes; any other answer, a bare Enter included, cancels. When stdin is not
// a terminal it asks nothing and fails at once: nobody is there to answer,
// and waiting for an answer would hang a script. Stdin decides, not
// stdout, because the answer is read from stdin: `secret rm foo | tee log`
// still asks. The question goes to stderr.
func (cli *Instance) confirm(cmd *cobra.Command, question string) error {
answers := cli.terminal
if answers == nil {
answers = cmd.InOrStdin()
if !isTerminal(answers) {
return errNoTerminal
}
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N] ", question)
answer, err := bufio.NewReader(answers).ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
return fmt.Errorf("failed to read the answer: %w", err)
}
switch strings.ToLower(strings.TrimSpace(answer)) {
case "y", "yes":
return nil
default:
return errNotConfirmed
}
}
// isTerminal reports whether r is a terminal.
func isTerminal(r io.Reader) bool {
file, ok := r.(*os.File)
return ok && term.IsTerminal(int(file.Fd()))
}
+411
View File
@@ -0,0 +1,411 @@
// Confirmation Tests
//
// `secret rm`, `secret version rm`, `secret vault remove` and
// `secret unlocker remove` ask the user to confirm on a terminal, naming
// what they are about to remove, and remove it only on y or yes. --force
// skips the question. Without --force, a command whose stdin is not a
// terminal fails at once, since nobody is there to answer.
//
// The tests answer through Instance.terminal, which stands in for a
// terminal. Without it, whether stdin is a terminal decides; the tests in
// integration_test.go that run `secret rm` on a pseudo-terminal cover that.
//nolint:testpackage // sets the unexported terminal field of Instance
package cli
import (
"bufio"
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// confirmTestSecret is the secret the tests remove, or remove a
// version of, in the vault "work".
confirmTestSecret = "test/secret"
// lastUnlockerRemoval names the case that removes the only unlocker.
lastUnlockerRemoval = "unlocker rm, the last one"
)
// removal is one removal command, set up on its own state directory.
type removal struct {
fs afero.Fs
run func(cli *Instance, cmd *cobra.Command, force bool) error
// removed is the directory the command removes.
removed string
// question is the question the command asks.
question string
}
// newConfirmTestVaults returns an in-memory state directory with the
// vaults "other" and "work", the current one. "work" holds two versions of
// confirmTestSecret and the given number of PGP unlockers. It returns the
// directory of "work" and the older version.
func newConfirmTestVaults(
t *testing.T, unlockers int,
) (*afero.MemMapFs, string, string) {
t.Helper()
fs := &afero.MemMapFs{}
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
require.NoError(t, err)
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false)
addTestSecret(t, vlt, []byte("newer"), true)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
versions, err := secret.ListVersions(fs,
filepath.Join(vaultDir, "secrets.d", "test%secret"))
require.NoError(t, err)
require.Len(t, versions, 2)
for i := range unlockers {
writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"),
fmt.Sprintf("pgp-%d", i),
time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC),
listTestGPGKeyID+string(rune('A'+i)))
}
// ListVersions lists the newest version first.
return fs, vaultDir, versions[1]
}
// newRemoval sets up the removal the command names.
func newRemoval(t *testing.T, command string) removal {
t.Helper()
unlockers := 2
if command == lastUnlockerRemoval {
unlockers = 1
}
fs, workDir, older := newConfirmTestVaults(t, unlockers)
// The first unlocker's directory name, written by newConfirmTestVaults
unlockerID := "pgp-0"
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.UnlockersRemove(unlockerID, force, cmd)
}
switch command {
case "rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveSecret(cmd, confirmTestSecret, force)
},
removed: filepath.Join(workDir, "secrets.d", "test%secret"),
question: "Permanently remove secret 'test/secret' and its 2 " +
"version(s) from vault 'work'?",
}
case "version rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveVersion(cmd, confirmTestSecret, older, force)
},
removed: filepath.Join(
workDir, "secrets.d", "test%secret", "versions", older),
question: "Permanently remove version " + older +
" of secret 'test/secret' from vault 'work'?",
}
case "vault rm":
return removal{
fs: fs,
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
return cli.RemoveVault(cmd, "work", force)
},
removed: workDir,
question: "Permanently remove vault 'work' and its 1 secret(s)?",
}
case "unlocker rm":
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID +
"' from vault 'work'? It is not the vault's last unlocker.",
}
case lastUnlockerRemoval:
return removal{
fs: fs,
run: removeFirstUnlocker,
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
question: "Permanently remove unlocker '" + unlockerID +
"', the last unlocker of vault 'work', which holds 1 " +
"secret(s)? Without an unlocker the vault opens only " +
"with its mnemonic.",
}
}
t.Fatalf("no removal %q", command)
return removal{}
}
// removalCommands lists the commands newRemoval sets up.
func removalCommands() []string {
return []string{
"rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval,
}
}
// newConfirmTestCommand returns a command whose output is discarded and
// whose stderr, where the question goes, is the returned buffer.
func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) {
var stderr bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(&stderr)
return cmd, &stderr
}
// requireExists asserts whether the directory dir exists.
func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) {
t.Helper()
exists, err := afero.DirExists(fs, dir)
require.NoError(t, err)
require.Equal(t, want, exists, dir)
}
// TestConfirmAnswers checks which answers confirm accepts: y or yes, in
// any case, around which spaces do not matter.
func TestConfirmAnswers(t *testing.T) {
t.Parallel()
for answer, want := range map[string]error{
"y\n": nil,
"Y\n": nil,
"yes\n": nil,
" YES \n": nil,
"y": nil,
"\n": errNotConfirmed,
"": errNotConfirmed,
"n\n": errNotConfirmed,
"yy\n": errNotConfirmed,
"no\ny\n": errNotConfirmed,
} {
t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) {
t.Parallel()
cli := &Instance{terminal: strings.NewReader(answer)}
cmd, stderr := newConfirmTestCommand()
err := cli.confirm(cmd, "Remove it?")
require.ErrorIs(t, err, want)
assert.Equal(t, "Remove it? [y/N] ", stderr.String())
})
}
}
// TestRemovalAnsweredYesRemoves checks that each removal asks its question
// and removes what it names when the user answers y.
func TestRemovalAnsweredYesRemoves(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
requireExists(t, r.fs, r.removed, true)
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cli.terminal = strings.NewReader("y\n")
cmd, stderr := newConfirmTestCommand()
require.NoError(t, r.run(cli, cmd, false))
assert.Equal(t, r.question+" [y/N] ", stderr.String())
requireExists(t, r.fs, r.removed, false)
})
}
}
// TestRemovalDeclinedLeavesEverything checks that each removal changes
// nothing when the user answers anything but y or yes, a bare Enter
// included.
func TestRemovalDeclinedLeavesEverything(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
for _, answer := range []string{"\n", "n\n", ""} {
t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
before := stateDirModTimes(t, r.fs)
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cli.terminal = strings.NewReader(answer)
cmd, stderr := newConfirmTestCommand()
err := r.run(cli, cmd, false)
require.ErrorIs(t, err, errNotConfirmed)
assert.Equal(t, r.question+" [y/N] ", stderr.String())
assert.Equal(t, before, stateDirModTimes(t, r.fs))
})
}
}
}
// TestRemovalForcedAsksNothing checks that each removal with --force
// removes what it would have named without asking, and without reading
// its input, which is not a terminal.
func TestRemovalForcedAsksNothing(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
input := strings.NewReader("n\n")
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cmd, stderr := newConfirmTestCommand()
cmd.SetIn(input)
require.NoError(t, r.run(cli, cmd, true))
assert.Empty(t, stderr.String(), "asked with --force")
assert.Equal(t, 2, input.Len(), "read its input with --force")
requireExists(t, r.fs, r.removed, false)
})
}
}
// TestRemovalWithoutTerminalFailsAtOnce checks that each removal without
// --force, whose input is not a terminal, fails at once telling the user
// to pass --force, and changes nothing. The input is a pipe that nobody
// writes to or closes, so reading it would block for good.
func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
t.Parallel()
for _, command := range removalCommands() {
t.Run(command, func(t *testing.T) {
t.Parallel()
r := newRemoval(t, command)
before := stateDirModTimes(t, r.fs)
input, inputWriter, err := os.Pipe()
require.NoError(t, err)
t.Cleanup(func() {
_ = inputWriter.Close()
_ = input.Close()
})
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
cmd, stderr := newConfirmTestCommand()
cmd.SetIn(input)
done := make(chan error, 1)
go func() { done <- r.run(cli, cmd, false) }()
select {
case err := <-done:
require.ErrorIs(t, err, errNoTerminal)
assert.Contains(t, err.Error(), "pass --force")
case <-time.After(lockWait):
// Closing the pipe ends the read, and frees the lock if
// the command holds it.
_ = inputWriter.Close()
t.Fatal("waited for an answer on input that is not a terminal")
}
assert.Empty(t, stderr.String(), "asked without a terminal")
assert.Equal(t, before, stateDirModTimes(t, r.fs))
})
}
}
// TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits
// for its answer, another command can take the state directory lock and
// change the secret, and that the removal then removes nothing, since the
// secret is no longer what the question named.
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
t.Parallel()
r := newRemoval(t, "rm")
answers, answerWriter := io.Pipe()
questions, questionWriter := io.Pipe()
// Closing the answers ends the read if the test fails while the
// command waits for one.
t.Cleanup(func() { _ = answerWriter.Close() })
rm := NewCLIInstanceWithStateDir(r.fs, testStateDir)
rm.terminal = answers
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
cmd.SetErr(questionWriter)
done := make(chan error, 1)
go func() { done <- r.run(rm, cmd, false) }()
question, err := bufio.NewReader(questions).ReadString(']')
require.NoError(t, err)
require.Equal(t, r.question+" [y/N]", question)
// Adds a third version while rm waits for its answer.
add := NewCLIInstanceWithStateDir(r.fs, testStateDir)
add.Mnemonic = testMnemonicBuffer(t)
add.cmd = &cobra.Command{}
add.cmd.SetIn(strings.NewReader("newest"))
add.cmd.SetOut(io.Discard)
added := make(chan error, 1)
go func() { added <- add.AddSecret(confirmTestSecret, true) }()
select {
case err := <-added:
require.NoError(t, err)
case <-time.After(lockWait):
t.Fatal("secret add waited for the lock while secret rm asked")
}
_, err = answerWriter.Write([]byte("y\n"))
require.NoError(t, err)
select {
case err := <-done:
require.ErrorIs(t, err, errChangedWhileAsking)
case <-time.After(lockWait):
t.Fatal("secret rm did not finish once answered")
}
requireExists(t, r.fs, r.removed, true)
}
+251 -16
View File
@@ -2,6 +2,11 @@ package cli_test
import ( import (
"bytes" "bytes"
"io"
"maps"
"os"
"slices"
"strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
@@ -20,16 +25,27 @@ import (
// decrypted any more. Each must refuse, change nothing, and leave every // decrypted any more. Each must refuse, change nothing, and leave every
// vault's secret readable through its passphrase unlocker. // vault's secret readable through its passphrase unlocker.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // the cases share cmd
func TestCreateExistingVaultChangesNothing(t *testing.T) { func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
// newCLI returns an instance on fs given the mnemonic and the unlock
// passphrase, as from the environment
newCLI := func(fs afero.Fs) *cli.Instance {
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return c
}
// `secret init`, `secret vault create work`, `secret vault select // `secret init`, `secret vault create work`, `secret vault select
// default`, and the secret "x" in each vault. "work" is then not the // default`, and the secret "x" in each vault. "work" is then not the
// current vault, which creating it again must not change. // current vault, which creating it again must not change.
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := newCLI(fs)
cmd := &cobra.Command{} cmd := &cobra.Command{}
require.NoError(t, c.Init(cmd)) require.NoError(t, c.Init(cmd))
@@ -74,7 +90,7 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
fs := newFsFromSnapshot(t, before) fs := newFsFromSnapshot(t, before)
err := tt.run(cli.NewCLIInstanceWithStateDir(fs, testStateDir)) err := tt.run(newCLI(fs))
require.EqualError(t, err, tt.want) require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
@@ -85,10 +101,11 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
// reading each vault's secret once from it shows that it still decrypts // reading each vault's secret once from it shows that it still decrypts
// after each case. Without the mnemonic, reading a secret goes through // after each case. Without the mnemonic, reading a secret goes through
// the vault's passphrase unlocker, which is slow. // the vault's passphrase unlocker, which is slow.
t.Setenv(secret.EnvMnemonic, "")
for _, name := range vaults { for _, name := range vaults {
value, err := vault.NewVault(fs, testStateDir, name).GetSecret("x") vlt := vault.NewVault(fs, testStateDir, name)
vlt.UnlockPassphrase = passphrase
value, err := vlt.GetSecret("x")
require.NoError(t, err) require.NoError(t, err)
unchanged := bytes.Equal([]byte("value"), value.Bytes()) unchanged := bytes.Equal([]byte("value"), value.Bytes())
@@ -98,19 +115,43 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
} }
} }
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
// `secret vault create` put the mnemonic into the process environment,
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
// leave neither in the environment.
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
t.Setenv(secret.EnvStateDir, t.TempDir())
run := func(args ...string) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// With no terminal to prompt on, this succeeds only if the command
// read both variables
_, err := cli.ExecuteCommandInProcess(args, "", nil)
require.NoError(t, err)
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
_, set := os.LookupEnv(name)
require.False(t, set, "%s is set after %v", name, args)
}
}
run("init")
run("vault", "create", "work")
}
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the // TestStopAtPassphrasePromptLeavesNothing is a regression test for the
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or // review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
// `secret vault create` stopped at the passphrase prompt left a vault with // `secret vault create` stopped at the passphrase prompt left a vault with
// no unlocker, which neither command would then create again. Each must ask // no unlocker, which neither command would then create again. Each must ask
// for the passphrase before writing anything. // for the passphrase before writing anything.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // the cases share cmd
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) { func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
// Without the passphrase in the environment, both commands prompt for
// it, which fails because the tests do not run in a terminal.
t.Setenv(secret.EnvUnlockPassphrase, "")
// An empty state directory for `secret init`, and one holding the vault // An empty state directory for `secret init`, and one holding the vault
// "default" for `secret vault create work`. // "default" for `secret vault create work`.
@@ -118,7 +159,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms)) require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs() withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default") _, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -144,10 +185,204 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
before := snapshotStateDir(t, tt.fs) before := snapshotStateDir(t, tt.fs)
err := tt.run(cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)) // Given no unlock passphrase, both commands prompt for it, which
// fails because the tests do not run in a terminal.
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
c.Mnemonic = mnemonic
err := tt.run(c)
require.ErrorContains(t, err, "failed to read passphrase") require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs)) require.Equal(t, before, snapshotStateDir(t, tt.fs))
}) })
} }
} }
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
// https://git.eeqj.de/sneak/secret/issues/105: `secret init` or `secret vault
// create` killed after the passphrase prompt but before the unlocker was
// written left a vault with no unlocker, which neither command would then
// create again. After the prompt, each command changes the state directory
// only through vault.CreateVault. The test makes that call as the command
// does and records the state directory before each change it makes, and once
// after it returns: what a stop at that point leaves. Each must hold either
// no vault, and not name it current, or exactly the finished vault, which
// opens with the passphrase through its current unlocker. The command run
// again after a stop first takes the lock, which must delete what the stop
// left under a temporary name. Running the command is slow, so it runs once
// on each different state the lock leaves, and must create the vault there,
// or refuse the one there.
//
//nolint:paralleltest // commands on the in-memory filesystem share one lock
func TestStopDuringCreateLeavesWholeVaultOrNone(t *testing.T) {
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
t.Run("init", func(t *testing.T) {
// From an empty state directory
fs := afero.NewMemMapFs()
require.NoError(t, fs.MkdirAll(testStateDir, secret.DirPerms))
requireStopsLeaveWholeVaultOrNone(t, fs, "default",
"failed to create default vault: vault default already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.Init(cmd) })
})
t.Run("vault create work", func(t *testing.T) {
// From a state directory holding the vault "default"
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err)
requireStopsLeaveWholeVaultOrNone(t, fs, "work", "vault work already exists",
mnemonic, passphrase,
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") })
})
}
// requireStopsLeaveWholeVaultOrNone checks, as
// TestStopDuringCreateLeavesWholeVaultOrNone describes, the stops of the
// command run, creating the vault name on fs with mnemonic and passphrase.
// Run again where the vault is there, the command must fail with exists.
func requireStopsLeaveWholeVaultOrNone(
t *testing.T, fs afero.Fs, name, exists string,
mnemonic, passphrase *memguard.LockedBuffer,
run func(c *cli.Instance) error,
) {
t.Helper()
var stops []map[string]string
record := func() { stops = append(stops, snapshotStateDir(t, fs)) }
_, err := vault.CreateVault(hookFs{Fs: fs, before: record},
testStateDir, name, mnemonic, passphrase)
require.NoError(t, err)
record()
vaultDir := testStateDir + "/vaults.d/" + name
require.NotContains(t, stops[0], vaultDir+"/", "no stop before the vault")
finished := entriesUnder(stops[len(stops)-1], vaultDir)
opener := vault.NewVault(fs, testStateDir, name)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
require.Equal(t, finished[vaultDir+"/pub.age"], key.Recipient().String())
// Each different state the command run again finds once it holds the lock
var locked []map[string]string
for i, stop := range stops {
if _, there := stop[vaultDir+"/"]; there {
require.Equal(t, finished, entriesUnder(stop, vaultDir),
"stop %d left a partial vault", i)
} else {
require.NotEqual(t, name, stop[testStateDir+"/currentvault"],
"stop %d made a missing vault current", i)
}
stopped := newFsFromSnapshot(t, stop)
release, err := vault.LockStateDir(stopped, testStateDir)
require.NoError(t, err)
release()
state := snapshotStateDir(t, stopped)
for path := range state {
require.NotContains(t, path, ".tmp-", "stop %d", i)
}
if !slices.ContainsFunc(locked, func(s map[string]string) bool {
return maps.Equal(s, state)
}) {
locked = append(locked, state)
}
}
for _, state := range locked {
c := cli.NewCLIInstanceWithStateDir(newFsFromSnapshot(t, state), testStateDir)
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
if _, there := state[vaultDir+"/"]; there {
require.EqualError(t, run(c), exists)
} else {
require.NoError(t, run(c))
}
}
}
// entriesUnder returns the entries of a tree recorded by snapshotStateDir
// that are under dir.
func entriesUnder(tree map[string]string, dir string) map[string]string {
entries := map[string]string{}
for path, content := range tree {
if strings.HasPrefix(path, dir+"/") {
entries[path] = content
}
}
return entries
}
// hookFs passes every call through to Fs, but first calls before for each
// call that can change the filesystem.
type hookFs struct {
afero.Fs
before func()
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) Create(name string) (afero.File, error) {
h.before()
return h.Fs.Create(name)
}
//nolint:ireturn // implements afero.Fs
func (h hookFs) OpenFile(
name string, flag int, perm os.FileMode,
) (afero.File, error) {
h.before()
return h.Fs.OpenFile(name, flag, perm)
}
func (h hookFs) Mkdir(name string, perm os.FileMode) error {
h.before()
return h.Fs.Mkdir(name, perm)
}
func (h hookFs) MkdirAll(path string, perm os.FileMode) error {
h.before()
return h.Fs.MkdirAll(path, perm)
}
func (h hookFs) Remove(name string) error {
h.before()
return h.Fs.Remove(name)
}
func (h hookFs) RemoveAll(path string) error {
h.before()
return h.Fs.RemoveAll(path)
}
func (h hookFs) Rename(oldname, newname string) error {
h.before()
return h.Fs.Rename(oldname, newname)
}
+10 -21
View File
@@ -41,6 +41,9 @@ func newCryptoCmd(
cli.cmd = cmd cli.cmd = cmd
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return run(cli, args[0], inputFile, outputFile) return run(cli, args[0], inputFile, outputFile)
}, },
} }
@@ -88,8 +91,7 @@ func (cli *Instance) storeNewEncryptionKey(
return nil, fmt.Errorf("failed to generate age key: %w", err) return nil, fmt.Errorf("failed to generate age key: %w", err)
} }
// Store the generated key directly in a secure buffer secureBuffer := secret.IdentityToLockedBuffer(identity)
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
err = vlt.AddSecret(secretName, secureBuffer, false) err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil { if err != nil {
@@ -128,7 +130,7 @@ func (cli *Instance) resolveEncryptionKey(
} }
// Secret exists, get the age secret key from it // Secret exists, get the age secret key from it
secretBuffer, err := cli.getSecretValue(vlt, secretObj) secretBuffer, err := vlt.GetSecret(secretName)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get secret value: %w", err) return nil, fmt.Errorf("failed to get secret value: %w", err)
} }
@@ -156,6 +158,8 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get or create the age secret key for this secret // Get or create the age secret key for this secret
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName) keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
if err != nil { if err != nil {
@@ -230,6 +234,8 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if secret exists // Check if secret exists
secretObj := secret.NewSecret(vlt, secretName) secretObj := secret.NewSecret(vlt, secretName)
@@ -243,7 +249,7 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
} }
// Get the age secret key from the secret // Get the age secret key from the secret
secretBuffer, err := cli.getSecretValue(vlt, secretObj) secretBuffer, err := vlt.GetSecret(secretName)
if err != nil { if err != nil {
return fmt.Errorf("failed to get secret value: %w", err) return fmt.Errorf("failed to get secret value: %w", err)
} }
@@ -307,20 +313,3 @@ func isValidAgeSecretKey(key string) bool {
return err == nil return err == nil
} }
// getSecretValue retrieves the value of a secret using the appropriate
// unlocker
func (cli *Instance) getSecretValue(
vlt *vault.Vault, secretObj *secret.Secret,
) (*memguard.LockedBuffer, error) {
if os.Getenv(secret.EnvMnemonic) != "" {
return secretObj.GetValue(nil)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
return secretObj.GetValue(unlocker)
}
+5
View File
@@ -76,6 +76,9 @@ func newGenerateSecretCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.GenerateSecret(cmd, args[0], length, secretType, force) return cli.GenerateSecret(cmd, args[0], length, secretType, force)
}, },
} }
@@ -167,6 +170,8 @@ func (cli *Instance) GenerateSecret(
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Protect the generated secret immediately // Protect the generated secret immediately
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue)) secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
defer secretBuffer.Destroy() defer secretBuffer.Destroy()
+33 -75
View File
@@ -6,13 +6,10 @@ import (
"log" "log"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"filippo.io/age"
"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"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
@@ -39,16 +36,20 @@ func RunInit(cmd *cobra.Command, _ []string) error {
log.Fatalf("failed to initialize CLI: %v", err) log.Fatalf("failed to initialize CLI: %v", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.Init(cmd) return cli.Init(cmd)
} }
// promptMnemonic reads the mnemonic from the environment or interactively. // promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
// The returned cleanup function must be deferred by the caller. // or reads it interactively. The returned cleanup function must be deferred
func promptMnemonic() (string, func(), error) { // by the caller.
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" { func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
if cli.Mnemonic != nil {
secret.Debug("Using mnemonic from environment variable") secret.Debug("Using mnemonic from environment variable")
return envMnemonic, func() {}, nil return cli.Mnemonic, func() {}, nil
} }
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
@@ -58,49 +59,12 @@ func promptMnemonic() (string, func(), error) {
if err != nil { if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err) secret.Debug("Failed to read mnemonic from stdin", "error", err)
return "", nil, fmt.Errorf("failed to read mnemonic: %w", err) return nil, nil, fmt.Errorf("failed to read mnemonic: %w", err)
} }
fmt.Fprintln(os.Stderr) // Add newline after hidden input fmt.Fprintln(os.Stderr) // Add newline after hidden input
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil return mnemonicBuffer, mnemonicBuffer.Destroy, nil
}
// setupDefaultVault creates the default vault and derives its long-term
// identity from the mnemonic
func (cli *Instance) setupDefaultVault(
stateDir, mnemonicStr string,
) (*vault.Vault, *age.X25519Identity, error) {
// Create the default vault - it will handle key derivation internally
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
}
// Get the vault metadata to retrieve the derivation index
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
if err != nil {
secret.Debug("Failed to load vault metadata", "error", err)
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
}
// Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
if err != nil {
secret.Debug("Failed to derive long-term key", "error", err)
return nil, nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
return vlt, ltIdentity, nil
} }
// Init initializes the secret manager, holding the state directory lock // Init initializes the secret manager, holding the state directory lock
@@ -136,12 +100,13 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
} }
// Prompt for mnemonic // Prompt for mnemonic
mnemonicStr, cleanupMnemonic, err := promptMnemonic() mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
if err != nil { if err != nil {
return err return err
} }
defer cleanupMnemonic() defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" { if mnemonicStr == "" {
secret.Debug("Empty mnemonic provided") secret.Debug("Empty mnemonic provided")
@@ -162,44 +127,37 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
// Ask for the unlocker passphrase before creating the vault, so that // Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind // stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase() passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil { if err != nil {
return err return err
} }
defer passphraseBuffer.Destroy() defer cleanupPassphrase()
// Set mnemonic in environment for CreateVault to use // Create the default vault with its passphrase unlocker
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr) secret.Debug("Creating default vault")
defer restoreMnemonicEnv()
// Create the default vault and derive its long-term key vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr) mnemonic, passphraseBuffer)
if err != nil {
secret.Debug("Failed to create default vault", "error", err)
return fmt.Errorf("failed to create default vault: %w", err)
}
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil {
return fmt.Errorf("failed to get long-term key: %w", err)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil { if err != nil {
return err return err
} }
ltPubKey := ltIdentity.Recipient().String()
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
secret.Debug("Failed to create unlocker", "error", err)
return fmt.Errorf("failed to create unlocker: %w", err)
}
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
// private key to longterm.age, so no need to do it again here.
if cmd != nil { if cmd != nil {
cmd.Printf("\nDefault vault created and configured\n") cmd.Printf("\nDefault vault created and configured\n")
cmd.Printf("Long-term public key: %s\n", ltPubKey) cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID()) cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
cmd.Println("\nYour secret manager is ready to use!") cmd.Println("\nYour secret manager is ready to use!")
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,") cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
cmd.Println("unlockers are not required for secret operations.") cmd.Println("unlockers are not required for secret operations.")
+173 -16
View File
@@ -2,10 +2,13 @@
package cli_test package cli_test
import ( import (
"bufio"
"bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -16,7 +19,11 @@ import (
"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/secret"
"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/creack/pty"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -286,7 +293,7 @@ func TestSecretManagerIntegration(t *testing.T) {
// Test 25: Concurrent operations // Test 25: Concurrent operations
// Purpose: Test multiple simultaneous operations // Purpose: Test multiple simultaneous operations
// Expected: Proper locking/synchronization, no corruption // Expected: Proper locking/synchronization, no corruption
test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv) test25ConcurrentOperations(t, tempDir, secretPath, testMnemonic, runSecret)
// Test 26: Large secret values // Test 26: Large secret values
// Purpose: Test with large secret values (e.g., certificates) // Purpose: Test with large secret values (e.g., certificates)
@@ -366,8 +373,15 @@ 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, "passphrase") passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker metadata // Check unlocker metadata
@@ -382,13 +396,6 @@ 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,7 +544,8 @@ 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
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase") currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
verifyFileExists(t, passphraseUnlockerDir) verifyFileExists(t, passphraseUnlockerDir)
// Check unlocker files // Check unlocker files
@@ -2009,28 +2017,35 @@ func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic,
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2))) assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
} }
func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test25ConcurrentOperations(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) {
t.Helper() t.Helper()
// Make sure we're in default vault // Make sure we're in default vault
_, err := runSecret("vault", "select", "default") _, err := runSecret("vault", "select", "default")
require.NoError(t, err, "vault select should succeed") require.NoError(t, err, "vault select should succeed")
// Run multiple concurrent reads // Run multiple concurrent reads, as separate processes: within one
// process the first command to read the mnemonic would unset it for
// the others
const numReaders = 5 const numReaders = 5
errCh := make(chan error, numReaders) errCh := make(chan error, numReaders)
for i := range numReaders { for i := range numReaders {
go func(id int) { go func(id int) {
output, err := runSecretWithEnv(map[string]string{ cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password")
secret.EnvMnemonic: testMnemonic, cmd.Env = []string{
}, "get", "database/password") secret.EnvStateDir + "=" + tempDir,
secret.EnvMnemonic + "=" + testMnemonic,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.Output()
switch { switch {
case err != nil: case err != nil:
errCh <- fmt.Errorf("reader %d failed: %w", id, err) errCh <- fmt.Errorf("reader %d failed: %w", id, err)
case strings.TrimSpace(output) == "": case strings.TrimSpace(string(output)) == "":
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id) errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
default: default:
errCh <- nil errCh <- nil
@@ -2535,3 +2550,145 @@ func copyFile(src, dst string) error {
return nil return nil
} }
// secretRmCommand makes a state directory whose vault "default" holds the
// secret "x", and returns `secret rm x` on the built binary against it, and
// the directory of "x". The vault has no unlocker, so making it derives no
// key from a passphrase.
func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
t.Helper()
stateDir := t.TempDir()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic, nil)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("x", value, false))
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "rm", "x")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
return cmd, filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "x")
}
// TestRemoveWithoutTerminalFailsAtOnce runs `secret rm` without --force,
// with a stdin that is not a terminal and never delivers anything, as in a
// script or a CI job. It must fail at once, telling the user to pass
// --force, instead of waiting for an answer, and remove nothing.
func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
t.Parallel()
// Nobody writes to or closes the pipe, so reading it would block for good.
stdin, stdinWriter, err := os.Pipe()
require.NoError(t, err)
defer func() {
_ = stdinWriter.Close()
_ = stdin.Close()
}()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
cmd, secretDir := secretRmCommand(ctx, t)
cmd.Stdin = stdin
output, err := cmd.CombinedOutput()
require.NoError(t, ctx.Err(), "secret rm waited for an answer")
require.Error(t, err)
assert.Contains(t, string(output), "pass --force")
assert.DirExists(t, secretDir)
}
// The next two tests run `secret rm` with a terminal on stdin or on stdout
// and stderr, not both: whether it asks must depend on stdin alone, where
// the answer is read from. pty.Open returns the two ends of a new terminal:
// tty is the end a program uses as its terminal, and ptmx the end the test
// reads what the terminal shows from and types into.
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
// terminal. stdin is a pipe, so nobody can answer there, and the command
// must fail as in a script, removing nothing.
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
cmd, secretDir := secretRmCommand(ctx, t)
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
cmd.Stdin = strings.NewReader("y\n")
cmd.Stdout = tty
cmd.Stderr = tty
require.NoError(t, cmd.Start())
_ = tty.Close()
// The read ends once secret rm has exited and so closed the terminal.
shown, _ := io.ReadAll(ptmx)
require.Error(t, cmd.Wait())
assert.Contains(t, string(shown), "pass --force")
assert.DirExists(t, secretDir)
}
// TestRemoveAsksAtTerminalOnStdin runs `secret rm x | cat` at a terminal.
// It must ask on the terminal, and remove the secret when y is typed there.
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
cmd, secretDir := secretRmCommand(ctx, t)
ptmx, tty, err := pty.Open()
require.NoError(t, err)
defer func() { _ = ptmx.Close() }()
cmd.Stdin = tty
// Not a file, so exec.Cmd connects stdout through a pipe.
cmd.Stdout = io.Discard
cmd.Stderr = tty
require.NoError(t, cmd.Start())
_ = tty.Close()
var (
shown []byte
char byte
)
terminal := bufio.NewReader(ptmx)
for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
char, err = terminal.ReadByte()
require.NoError(t, err, "secret rm ended without asking: %s", shown)
shown = append(shown, char)
}
_, err = ptmx.WriteString("y\n")
require.NoError(t, err)
require.NoError(t, cmd.Wait())
assert.NoDirExists(t, secretDir)
}
+74
View File
@@ -0,0 +1,74 @@
package cli_test
import (
"io"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// TestLeftoversRemovedByNextChangingCommand is a regression test for
// https://git.eeqj.de/sneak/secret/issues/75. It plants what a command
// killed part-way leaves in each directory where secret.TempDirFor and
// secret.WriteFileAtomic make temporary entries: a temporary directory
// holding a vault, secret, unlocker or version being added or removed, and
// a temporary file beside a file being replaced. `secret list` must leave
// them all, and the next command that takes the state directory lock, here
// `secret vault select` of the vault already current, must delete exactly
// them: a vault named like a temporary directory stays. The copy has no
// lock file yet, so that command, as after a killed one, finds no mark that
// the last holder of the lock finished.
func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
require.NoError(t, err)
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
before := snapshotStateDir(t, fs)
vaultDir := testStateDir + "/vaults.d/default"
secretDir := vaultDir + "/secrets.d/x"
versions, err := secret.ListVersions(fs, secretDir)
require.NoError(t, err)
require.Len(t, versions, 1)
for _, dir := range []string{
testStateDir + "/.tmp-1/default",
vaultDir + "/.tmp-2/x",
secretDir + "/.tmp-3/" + testVersion,
} {
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
require.NoError(t, afero.WriteFile(fs, dir+"/value.age",
[]byte("encrypted"), secret.FilePerms))
}
for _, file := range []string{
testStateDir + "/.currentvault.tmp-4",
vaultDir + "/.current-unlocker.tmp-5",
secretDir + "/.current.tmp-6",
secretDir + "/versions/" + versions[0] + "/.metadata.age.tmp-7",
} {
require.NoError(t, afero.WriteFile(fs, file,
[]byte("partial"), secret.FilePerms))
}
planted := snapshotStateDir(t, fs)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
require.NoError(t, c.ListSecrets(cmd, false, false, ""))
require.Equal(t, planted, snapshotStateDir(t, fs))
require.NoError(t, c.SelectVault(cmd, "default"))
require.Equal(t, before, snapshotStateDir(t, fs))
}
+46 -30
View File
@@ -52,15 +52,18 @@ func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
} }
// addAtOnce runs one add of the secret name per value, all at once, and // addAtOnce runs one add of the secret name per value, all at once, and
// returns their errors. // returns their errors. Each add is given mnemonic, which a forced add
// needs.
func addAtOnce( func addAtOnce(
fs afero.Fs, stateDir, name string, force bool, values []string, fs afero.Fs, stateDir, name string, force bool, values []string,
mnemonic *memguard.LockedBuffer,
) []error { ) []error {
errs := make(chan error, len(values)) errs := make(chan error, len(values))
for _, value := range values { for _, value := range values {
go func() { go func() {
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = mnemonic
cli.cmd = &cobra.Command{} cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader(value)) cli.cmd.SetIn(strings.NewReader(value))
@@ -92,9 +95,9 @@ func numbered(prefix string, count int) []string {
// forced adds read the same highest version number and overwrite each // forced adds read the same highest version number and overwrite each
// other's version. With it they behave as if run one after another. // other's version. With it they behave as if run one after another.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // times commands against the in-memory lock all tests share
func TestConcurrentAddsKeepEveryVersion(t *testing.T) { func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
const adds = 8 const adds = 8
@@ -107,14 +110,14 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()}, {"real", afero.NewOsFs(), t.TempDir()},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default") _, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
// One add creates the secret; the others find that it exists // One add creates the secret; the others find that it exists
created := 0 created := 0
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false, for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
numbered("create", adds)) { numbered("create", adds), mnemonic) {
if err == nil { if err == nil {
created++ created++
} else { } else {
@@ -126,13 +129,15 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
// Every forced add stores a version of its own // Every forced add stores a version of its own
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true, for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
numbered("force", adds)) { numbered("force", adds), mnemonic) {
require.NoError(t, err) require.NoError(t, err)
} }
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir) vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = mnemonic
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
@@ -176,11 +181,11 @@ func (r *readNotifier) Read(p []byte) (int, error) {
// taken the state directory lock before reading, it would hold the lock // taken the state directory lock before reading, it would hold the lock
// while waiting for encrypt's output, and encrypt would wait for the lock // while waiting for encrypt's output, and encrypt would wait for the lock
// to store its key: neither would finish. // to store its key: neither would finish.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptPipedIntoAdd(t *testing.T) { func TestEncryptPipedIntoAdd(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default") _, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
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))
@@ -236,8 +241,10 @@ func TestFailedCommandReleasesLock(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
// Fails once it holds the lock: there is no current vault // Fails once it holds the lock: there is no current vault. Without
err := cli.RemoveSecret(&cobra.Command{}, "missing", false) // --force it would fail before taking the lock, on the check it makes
// before asking.
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
require.Error(t, err) require.Error(t, err)
select { select {
@@ -283,14 +290,16 @@ func setupEveryCommand(
) (string, string) { ) (string, string) {
t.Helper() t.Helper()
other, err := vault.CreateVault(fs, testStateDir, "other") mnemonic := testMnemonicBuffer(t)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
otherDir, err := other.GetDirectory() otherDir, err := other.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age"))) require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work") vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false) addTestSecret(t, vlt, []byte("older"), false)
@@ -353,7 +362,6 @@ func requireWaitsForLock(
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker) olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
before := stateDirModTimes(t, fs)
release, err := vault.LockStateDir(fs, testStateDir) release, err := vault.LockStateDir(fs, testStateDir)
require.NoError(t, err) require.NoError(t, err)
@@ -363,7 +371,15 @@ func requireWaitsForLock(
release = sync.OnceFunc(release) release = sync.OnceFunc(release)
defer release() defer release()
// Taken only now, since taking the lock writes the lock file.
before := stateDirModTimes(t, fs)
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer unlockPassphrase.Destroy()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
cli.UnlockPassphrase = unlockPassphrase
cli.cmd = &cobra.Command{} cli.cmd = &cobra.Command{}
cli.cmd.SetIn(strings.NewReader("value")) cli.cmd.SetIn(strings.NewReader("value"))
cli.cmd.SetOut(io.Discard) cli.cmd.SetOut(io.Discard)
@@ -400,11 +416,8 @@ func requireWaitsForLock(
// TestChangingCommandsWaitForLock checks that each command that changes the // TestChangingCommandsWaitForLock checks that each command that changes the
// state directory waits for its lock. // state directory waits for its lock.
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
func TestChangingCommandsWaitForLock(t *testing.T) { func TestChangingCommandsWaitForLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
for _, tc := range []struct { for _, tc := range []struct {
name string name string
withUnlocker bool withUnlocker bool
@@ -422,8 +435,8 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
{"encrypt", false, func(cli *Instance, _, _ string) error { {"encrypt", false, func(cli *Instance, _, _ string) error {
return cli.Encrypt("key", testInput, "") return cli.Encrypt("key", testInput, "")
}}, }},
{"rm", false, func(cli *Instance, _, _ string) error { {"rm --force", false, func(cli *Instance, _, _ string) error {
return cli.RemoveSecret(cli.cmd, "test/secret", false) return cli.RemoveSecret(cli.cmd, "test/secret", true)
}}, }},
{"move", false, func(cli *Instance, _, _ string) error { {"move", false, func(cli *Instance, _, _ string) error {
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false) return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
@@ -431,8 +444,8 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
{"version promote", false, func(cli *Instance, olderVersion, _ string) error { {"version promote", false, func(cli *Instance, olderVersion, _ string) error {
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion) return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
}}, }},
{"version rm", false, func(cli *Instance, olderVersion, _ string) error { {"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion) return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
}}, }},
{"vault create", false, func(cli *Instance, _, _ string) error { {"vault create", false, func(cli *Instance, _, _ string) error {
return cli.CreateVault(cli.cmd, "created") return cli.CreateVault(cli.cmd, "created")
@@ -443,13 +456,13 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
{"vault import", false, func(cli *Instance, _, _ string) error { {"vault import", false, func(cli *Instance, _, _ string) error {
return cli.VaultImport(cli.cmd, "other") return cli.VaultImport(cli.cmd, "other")
}}, }},
{"vault rm", false, func(cli *Instance, _, _ string) error { {"vault rm --force", false, func(cli *Instance, _, _ string) error {
return cli.RemoveVault(cli.cmd, "other", false) return cli.RemoveVault(cli.cmd, "other", true)
}}, }},
{"unlocker add", false, func(cli *Instance, _, _ string) error { {"unlocker add", false, func(cli *Instance, _, _ string) error {
return cli.UnlockersAdd("passphrase", cli.cmd) return cli.UnlockersAdd("passphrase", cli.cmd)
}}, }},
{"unlocker rm", true, func(cli *Instance, _, unlockerID string) error { {"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
return cli.UnlockersRemove(unlockerID, true, cli.cmd) return cli.UnlockersRemove(unlockerID, true, cli.cmd)
}}, }},
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error { {"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
@@ -468,15 +481,18 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a // TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
// key that already exists, which only reads the state directory, finishes // key that already exists, which only reads the state directory, finishes
// while another command holds the state directory lock. // while another command holds the state directory lock.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) { func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) mnemonic := testMnemonicBuffer(t)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default") _, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600)) require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir) encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
encrypt.Mnemonic = mnemonic
encrypt.cmd = &cobra.Command{} encrypt.cmd = &cobra.Command{}
encrypt.cmd.SetOut(io.Discard) encrypt.cmd.SetOut(io.Discard)
@@ -505,11 +521,11 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
// state directory lock by the time it writes its output. Holding it while // state directory lock by the time it writes its output. Holding it while
// streaming would stall every other changing command for as long as the // streaming would stall every other changing command for as long as the
// stream lasts, and forever when the other end of the pipe is one of them. // stream lasts, and forever when the other end of the pipe is one of them.
//
//nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptStreamsUnlocked(t *testing.T) { func TestEncryptStreamsUnlocked(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default") _, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
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))
+22 -22
View File
@@ -6,7 +6,6 @@ import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -20,9 +19,9 @@ import (
// move within "work" left "work" the current vault. "default" is the current // move within "work" left "work" the current vault. "default" is the current
// vault in every case, and each case runs on its own copy of the state // vault in every case, and each case runs on its own copy of the state
// directory. // directory.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) { func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
require.Equal(t, "default", before[testStateDir+"/currentvault"]) require.Equal(t, "default", before[testStateDir+"/currentvault"])
@@ -48,31 +47,32 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
"mv work:nosuch work:y", "work:nosuch", "work:y", false, "mv work:nosuch work:y", "work:nosuch", "work:y", false,
"secret 'nosuch' not found", "secret 'nosuch' not found",
}, },
// Only an existing vault is used, so ".." cannot reach the state // Only an existing vault is used.
// directory itself.
{ {
"mv --force ..:x ..:y", "..:x", "..:y", true, "mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
"vault '..' does not exist", "vault 'nosuch' does not exist",
}, },
// Each of these spells "work" a second way. The spelling is not an // Each of these spells "work" a second way. The spelling is not a
// existing vault name, so the move is not taken for a move between // valid vault name, so the move is not taken for a move between two
// two vaults, which would delete the destination, here the source. // vaults, which would delete the destination, here the source.
{ {
"mv --force work:x work/:x", workX, "work/:x", true, "mv --force work:x work/:x", workX, "work/:x", true,
"vault 'work/' does not exist", vault.ValidateVaultName("work/").Error(),
}, },
{ {
"mv --force work/:x work:", "work/:x", "work:", true, "mv --force work/:x work:", "work/:x", "work:", true,
"vault 'work/' does not exist", vault.ValidateVaultName("work/").Error(),
}, },
{ {
"mv --force work:x ./work:x", workX, "./work:x", true, "mv --force work:x ./work:x", workX, "./work:x", true,
"vault './work' does not exist", vault.ValidateVaultName("./work").Error(),
}, },
} }
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 +87,9 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x // TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and // work:y`, with "default" the current vault, renames "x" to "y" in "work" and
// leaves "default" the current vault. // leaves "default" the current vault.
//
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) { func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t) fs := newTwoVaultFs(t)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
@@ -112,10 +112,8 @@ func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
// the secret "x", and the secrets.d of "other" is a link to that of // the secret "x", and the secrets.d of "other" is a link to that of
// "default", so other:x is default:x. Each move must be rejected and leave // "default", so other:x is default:x. Each move must be rejected and leave
// the secret and the links as they were. // the secret and the links as they were.
//
//nolint:paralleltest // t.Setenv
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) { func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
const isSame = "is the same secret on this filesystem" const isSame = "is the same secret on this filesystem"
@@ -151,15 +149,17 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := t.TempDir() stateDir := t.TempDir()
vaultsDir := filepath.Join(stateDir, "vaults.d") vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault. // "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other") _, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default") vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
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 +200,12 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo" // and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
// with "Foo". // with "Foo".
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) { func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := t.TempDir() stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default") vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
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)
+104 -27
View File
@@ -33,6 +33,17 @@ const (
missingFile = "/no/such/file" missingFile = "/no/such/file"
) )
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir. // The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
// Creating a passphrase unlocker is slow by design, so the vaults are made // Creating a passphrase unlocker is slow by design, so the vaults are made
// once, by the first test that needs them. // once, by the first test that needs them.
@@ -52,13 +63,12 @@ var (
func newTwoVaultFs(t *testing.T) afero.Fs { func newTwoVaultFs(t *testing.T) afero.Fs {
t.Helper() t.Helper()
t.Setenv(secret.EnvMnemonic, testMnemonic)
twoVaultsOnce.Do(func() { twoVaultsOnce.Do(func() {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
for _, name := range []string{"work", "default"} { for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name) vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
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)
@@ -80,6 +90,8 @@ func newTwoVaultFs(t *testing.T) afero.Fs {
// snapshotStateDir maps every file under the state directory to its // snapshotStateDir maps every file under the state directory to its
// contents, and every directory, written with a trailing "/", to "". Two // contents, and every directory, written with a trailing "/", to "". Two
// snapshots are equal only if nothing in it was added, removed or changed. // snapshots are equal only if nothing in it was added, removed or changed.
// The lock file, which every command that takes the lock writes, is left
// out.
func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string { func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
t.Helper() t.Helper()
@@ -92,6 +104,10 @@ func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
return err return err
} }
if path == testStateDir+"/lock" {
return nil
}
if info.IsDir() { if info.IsDir() {
tree[path+"/"] = "" tree[path+"/"] = ""
@@ -160,10 +176,10 @@ func requireRejectedAndUnchanged(
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for // TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted // https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it. // the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
// Moves and imports use --force, so that only the name check stands in // Removals, moves and imports use --force, so that only the name check
// the way. // stands in the way.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // the cases share cmd
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) { func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// Creating a passphrase unlocker is slow by design, so the vaults are // Creating a passphrase unlocker is slow by design, so the vaults are
// created once and each case runs on its own copy of them. // created once and each case runs on its own copy of them.
@@ -171,7 +187,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+"/unlockers.d/passphrase/") require.Contains(t, before, vaultDir+"/current-unlocker")
require.Equal(t, "default", before[testStateDir+"/currentvault"]) require.Equal(t, "default", before[testStateDir+"/currentvault"])
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -181,17 +197,17 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
rejected string // the secret name the command must reject rejected string // the secret name the command must reject
run func(c *cli.Instance) error run func(c *cli.Instance) error
}{ }{
{"rm ..", "..", func(c *cli.Instance) error { {"rm --force ..", "..", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "..", false) return c.RemoveSecret(cmd, "..", true)
}}, }},
{"rm .", ".", func(c *cli.Instance) error { {"rm --force .", ".", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, ".", false) return c.RemoveSecret(cmd, ".", true)
}}, }},
{`rm ""`, "", func(c *cli.Instance) error { {`rm --force ""`, "", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "", false) return c.RemoveSecret(cmd, "", true)
}}, }},
{"rm ../../etc", "../../etc", func(c *cli.Instance) error { {"rm --force ../../etc", "../../etc", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "../../etc", false) return c.RemoveSecret(cmd, "../../etc", true)
}}, }},
{"mv --force .. x", "..", func(c *cli.Instance) error { {"mv --force .. x", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "..", "x", true) return c.MoveSecret(cmd, "..", "x", true)
@@ -234,8 +250,8 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
{"version promote ..", "..", func(c *cli.Instance) error { {"version promote ..", "..", func(c *cli.Instance) error {
return c.PromoteVersion(cmd, "..", testVersion) return c.PromoteVersion(cmd, "..", testVersion)
}}, }},
{"version rm ..", "..", func(c *cli.Instance) error { {"version rm --force ..", "..", func(c *cli.Instance) error {
return c.RemoveVersion(cmd, "..", testVersion) return c.RemoveVersion(cmd, "..", testVersion, true)
}}, }},
{"encrypt ..", "..", func(c *cli.Instance) error { {"encrypt ..", "..", func(c *cli.Instance) error {
return c.Encrypt("..", "", "") return c.Encrypt("..", "", "")
@@ -259,7 +275,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
// `secret version rm x ""` every version of x. A version argument is // `secret version rm x ""` every version of x. A version argument is
// accepted only if it is one of the versions `secret version list` lists. // accepted only if it is one of the versions `secret version list` lists.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // the cases share cmd
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) { func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t)) before := snapshotStateDir(t, newTwoVaultFs(t))
@@ -269,8 +285,8 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
command string command string
run func(c *cli.Instance, version string) error run func(c *cli.Instance, version string) error
}{ }{
{"version rm x", func(c *cli.Instance, version string) error { {"version rm --force x", func(c *cli.Instance, version string) error {
return c.RemoveVersion(cmd, "x", version) return c.RemoveVersion(cmd, "x", version, true)
}}, }},
{"version promote x", func(c *cli.Instance, version string) error { {"version promote x", func(c *cli.Instance, version string) error {
return c.PromoteVersion(cmd, "x", version) return c.PromoteVersion(cmd, "x", version)
@@ -292,17 +308,78 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
} }
} }
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm` // TestInvalidVaultNameLeavesStateUnchanged is a regression test for
// with a version that is not the current one removes that version and // https://git.eeqj.de/sneak/secret/issues/68, where
// changes nothing else. // `secret vault import ..` wrote a long-term key and an unlocker into the
// state directory itself, and `secret vault select ..` made it the current
// vault. Each command that takes a vault name must reject an invalid one
// before building a path from it. The instance is given the mnemonic and
// the passphrase, and moves and removals use --force, so that only the name
// check stands in the way.
// //
//nolint:paralleltest // newTwoVaultFs uses t.Setenv //nolint:paralleltest // the cases share cmd
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
before := snapshotStateDir(t, newTwoVaultFs(t))
mnemonic := testMnemonicBuffer(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
cmd := &cobra.Command{}
// Each command is a format with %q where the vault name goes.
commands := []struct {
command string
run func(c *cli.Instance, name string) error
}{
{"vault create %q", func(c *cli.Instance, name string) error {
return c.CreateVault(cmd, name)
}},
{"vault import %q", func(c *cli.Instance, name string) error {
return c.VaultImport(cmd, name)
}},
{"vault select %q", func(c *cli.Instance, name string) error {
return c.SelectVault(cmd, name)
}},
{"vault remove --force %q", func(c *cli.Instance, name string) error {
return c.RemoveVault(cmd, name, true)
}},
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
return c.MoveSecret(cmd, name+":x", "work:x", true)
}},
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
return c.MoveSecret(cmd, "default:x", name+":x", true)
}},
}
for _, tt := range commands {
for _, name := range []string{"", ".", "..", "a/b"} {
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
func(c *cli.Instance) error {
c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase
return tt.run(c, name)
})
})
}
}
}
// TestRemoveVersionRemovesOnlyThatVersion checks that
// `secret version rm --force` with a version that is not the current one
// removes that version and changes nothing else.
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)
@@ -318,7 +395,7 @@ func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
require.Contains(t, before, oldDir) require.Contains(t, before, oldDir)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir) c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1]) err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
require.NoError(t, err) require.NoError(t, err)
// Expected: the state as before without everything under oldDir. // Expected: the state as before without everything under oldDir.
@@ -336,9 +413,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)
+23 -2
View File
@@ -46,9 +46,30 @@ func newRootCmd() *cobra.Command {
Short: "A simple secrets manager", Short: "A simple secrets manager",
Long: `A simple secrets manager to store and retrieve sensitive ` + Long: `A simple secrets manager to store and retrieve sensitive ` +
`information securely.`, `information securely.`,
// Ensure usage is shown after errors // Cobra prints the error a command returns; Entry does not.
SilenceUsage: false,
SilenceErrors: false, SilenceErrors: false,
// Usage belongs only to a command called wrongly. Cobra has
// checked its arguments and flag values before this runs, but
// checks required flags (ValidateRequiredFlags) and flag groups
// (ValidateFlagGroups) only after it, so both are checked here
// to keep usage for them. An error after that comes from running
// the command, and usage would only bury it. A subcommand that
// sets its own PersistentPreRun replaces this one.
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
err := cmd.ValidateRequiredFlags()
if err != nil {
return err
}
err = cmd.ValidateFlagGroups()
if err != nil {
return err
}
cmd.SilenceUsage = true
return nil
},
} }
secret.Debug("Adding subcommands to root command") secret.Debug("Adding subcommands to root command")
+103 -34
View File
@@ -81,6 +81,9 @@ func newAddCmd() *cobra.Command {
cli.cmd = cmd // Set the command for stdin access cli.cmd = cmd // Set the command for stdin access
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
secret.Debug("Created CLI instance, calling AddSecret") secret.Debug("Created CLI instance, calling AddSecret")
return cli.AddSecret(args[0], force) return cli.AddSecret(args[0], force)
@@ -111,6 +114,9 @@ func newGetCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
// Without --version, get the current version. A given // Without --version, get the current version. A given
// --version is checked as typed, so an empty one is rejected. // --version is checked as typed, so an empty one is rejected.
if !cmd.Flags().Changed("version") { if !cmd.Flags().Changed("version") {
@@ -174,6 +180,9 @@ func newImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ImportSecret(cmd, args[0], sourceFile, force) return cli.ImportSecret(cmd, args[0], sourceFile, force)
}, },
} }
@@ -196,19 +205,25 @@ func newRemoveCmd() *cobra.Command {
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a secret from the vault", Short: "Remove a secret from the vault",
Long: `Remove a secret and all its versions from the current ` + Long: `Remove a secret and all its versions from the current ` +
`vault. This action is permanent and cannot be undone.`, `vault. This action is permanent and cannot be undone. ` +
`Asks for confirmation first; when stdin is not a terminal, ` +
`fails unless --force is given.`,
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 {
force, _ := cmd.Flags().GetBool("force")
cli, err := NewCLIInstance() cli, err := NewCLIInstance()
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
return cli.RemoveSecret(cmd, args[0], false) return cli.RemoveSecret(cmd, args[0], force)
}, },
} }
cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation")
return cmd return cmd
} }
@@ -248,6 +263,9 @@ The source secret is deleted after successful copy.`,
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.MoveSecret(cmd, args[0], args[1], force) return cli.MoveSecret(cmd, args[0], args[1], force)
}, },
} }
@@ -354,6 +372,8 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
secret.Debug("Got current vault", "vault_name", vlt.GetName()) secret.Debug("Got current vault", "vault_name", vlt.GetName())
// Read secret value directly into protected buffers // Read secret value directly into protected buffers
@@ -420,6 +440,8 @@ func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
value, err := vlt.GetSecret(secretName) value, err := vlt.GetSecret(secretName)
if err != nil { if err != nil {
return err return err
@@ -448,6 +470,8 @@ func (cli *Instance) GetSecretWithVersion(
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get the secret value // Get the secret value
value, err := vlt.GetSecretVersion(secretName, version) value, err := vlt.GetSecretVersion(secretName, version)
if err != nil { if err != nil {
@@ -633,6 +657,8 @@ func (cli *Instance) ImportSecret(
return err return err
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Read secret value from the source file into protected buffers // Read secret value from the source file into protected buffers
file, err := cli.fs.Open(sourceFile) file, err := cli.fs.Open(sourceFile)
if err != nil { if err != nil {
@@ -679,29 +705,64 @@ func (cli *Instance) ImportSecret(
return nil return nil
} }
// RemoveSecret removes a secret from the vault // RemoveSecret removes a secret and all its versions from the current
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error { // vault, after asking the user to confirm unless force is set.
func (cli *Instance) RemoveSecret(
cmd *cobra.Command, secretName string, force bool,
) error {
err := vault.ValidateSecretName(secretName) err := vault.ValidateSecretName(secretName)
if err != nil { if err != nil {
return err return err
} }
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found secretToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findSecretToRemove(secretName)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
// Get current vault err = secret.RemoveDirAtomic(cli.fs, found.dir)
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to remove secret: %w", err)
}
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
secretName, found.versions)
return nil
}
// secretToRemove is what removing a secret removes, as findSecretToRemove
// found it.
type secretToRemove struct {
// dir is the secret's directory, which holds all its versions.
dir string
versions int
// question names what is removed, for the user to confirm.
question string
}
// findSecretToRemove checks that the secret exists in the current vault
// and counts its versions.
func (cli *Instance) findSecretToRemove(
secretName string,
) (secretToRemove, error) {
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return secretToRemove{}, err
} }
// Check if secret exists
vaultDir, err := currentVlt.GetDirectory() vaultDir, err := currentVlt.GetDirectory()
if err != nil { if err != nil {
return err return secretToRemove{}, err
} }
encodedName := strings.ReplaceAll(secretName, "/", "%") encodedName := strings.ReplaceAll(secretName, "/", "%")
@@ -709,32 +770,30 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
exists, err := afero.DirExists(cli.fs, secretDir) exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err) return secretToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) return secretToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
} }
// Count versions for information // A secret without a versions directory has no versions, and can
versionsDir := filepath.Join(secretDir, "versions") // still be removed.
versionCount := 0 versions, err := afero.ReadDir(cli.fs, filepath.Join(secretDir, "versions"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
entries, err := afero.ReadDir(cli.fs, versionsDir) return secretToRemove{}, fmt.Errorf(
if err == nil { "failed to count the versions of secret '%s': %w", secretName, err)
versionCount = len(entries)
} }
// Remove the secret directory return secretToRemove{
err = secret.RemoveDirAtomic(cli.fs, secretDir) dir: secretDir,
if err != nil { versions: len(versions),
return fmt.Errorf("failed to remove secret: %w", err) question: fmt.Sprintf("Permanently remove secret '%s' and its %d "+
} "version(s) from vault '%s'?",
secretName, len(versions), currentVlt.GetName()),
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n", }, nil
secretName, versionCount)
return nil
} }
// MoveSecret moves or renames a secret (within or across vaults), holding // MoveSecret moves or renames a secret (within or across vaults), holding
@@ -811,9 +870,9 @@ func (cli *Instance) moveSecret(
cmd, vlt, srcSecretName, destSecretName, force) cmd, vlt, srcSecretName, destSecretName, force)
} }
// Both vaults must be existing vaults by exact name, so that two // Both vault names must be valid and name existing vaults exactly, so
// spellings of one vault, such as "work" and "work/", are never taken for // that two spellings of one vault, such as "work" and "work/", are never
// two vaults. A named vault does not become the current vault. // taken for two vaults. A named vault does not become the current vault.
srcVault, err := cli.existingVault(srcVaultName) srcVault, err := cli.existingVault(srcVaultName)
if err != nil { if err != nil {
return err return err
@@ -833,9 +892,15 @@ func (cli *Instance) moveSecret(
cmd, srcVault, srcSecretName, destVault, destSecretName, force) cmd, srcVault, srcSecretName, destVault, destSecretName, force)
} }
// existingVault returns the vault with the given name, or an error if there // existingVault returns the vault with the given name, or an error if the
// is none. Unlike vault.SelectVault, it leaves the current vault as it is. // name is not a valid vault name or there is no such vault. Unlike
// vault.SelectVault, it leaves the current vault as it is.
func (cli *Instance) existingVault(name string) (*vault.Vault, error) { func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
err := vault.ValidateVaultName(name)
if err != nil {
return nil, err
}
vaults, err := vault.ListVaults(cli.fs, cli.stateDir) vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to list vaults: %w", err) return nil, fmt.Errorf("failed to list vaults: %w", err)
@@ -987,6 +1052,10 @@ func (cli *Instance) moveSecretCrossVault(
destVault.Name, destSecretName) destVault.Name, destSecretName)
} }
// Copying needs the long-term keys of both vaults
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Unlock destination vault (will fail if neither mnemonic nor unlocker available) // Unlock destination vault (will fail if neither mnemonic nor unlocker available)
_, err = destVault.GetOrDeriveLongTermKey() _, err = destVault.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
+7 -10
View File
@@ -10,7 +10,6 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -71,11 +70,9 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set test mnemonic
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault // Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName) _, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
@@ -205,7 +202,7 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin // TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
// //
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault //nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretVariousSizes(t *testing.T) { func TestAddSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -265,7 +262,7 @@ func TestAddSecretVariousSizes(t *testing.T) {
// TestImportSecretVariousSizes tests importing secrets of various sizes from files // TestImportSecretVariousSizes tests importing secrets of various sizes from files
// //
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault //nolint:paralleltest // together the subtests lock more than the memlock limit
func TestImportSecretVariousSizes(t *testing.T) { func TestImportSecretVariousSizes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -325,7 +322,7 @@ func TestImportSecretVariousSizes(t *testing.T) {
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly // TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
// //
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault //nolint:paralleltest // together the subtests lock more than the memlock limit
func TestAddSecretBufferGrowth(t *testing.T) { func TestAddSecretBufferGrowth(t *testing.T) {
// Test various sizes that should trigger buffer growth // Test various sizes that should trigger buffer growth
sizes := []int{ sizes := []int{
@@ -392,9 +389,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
+374
View File
@@ -0,0 +1,374 @@
// Unlock Failure Tests
//
// When a vault cannot be opened through its current unlocker, because a
// file the unlocker needs is missing or the passphrase is wrong, the error
// keeps its cause and ends by saying that the mnemonic still opens that
// vault, but only for a vault that the mnemonic does open, and not when the
// passphrase could not be read at all. When a secret's current file is
// missing, the error says how to make a version current again. Each test
// that pins such advice also follows it.
package cli_test
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
// mnemonicAdvice ends the error when the current vault "default", which
// its mnemonic opens, cannot be opened through its current unlocker.
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
"to the mnemonic to give it a new unlocker"
// versionAdvice ends the error when a secret's current file cannot be
// read.
versionAdvice = "; this file only names the current version: " +
"'secret version list' lists the secret's versions, and " +
"'secret version promote' makes one of them current"
// unlockTestVaultDir is the directory of the vault "default" of
// newTwoVaultFs, the current vault, whose secret "x" is "value".
unlockTestVaultDir = testStateDir + "/vaults.d/default"
)
// currentUnlockerDir returns the directory of the current unlocker of the
// vault in vaultDir on fs.
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
t.Helper()
unlockerName, err := afero.ReadFile(fs,
filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
}
// newUnlockTestCLI returns the directory of the current unlocker of the
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
// instance on fs that has the unlock passphrase, as from the environment,
// but not the mnemonic.
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
t.Helper()
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
}
// discardCmd returns a command whose output is discarded.
func discardCmd() *cobra.Command {
cmd := &cobra.Command{}
cmd.SetOut(io.Discard)
return cmd
}
// getSecret returns what `secret get name` prints.
func getSecret(t *testing.T, c *cli.Instance, name string) string {
t.Helper()
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
require.NoError(t, c.GetSecret(cmd, name))
return out.String()
}
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
// file that opening the vault through its current unlocker needs is
// missing: it keeps the cause, which names the file, and ends with the
// advice that the mnemonic still opens the vault. The test then follows
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
// the vault a new unlocker, which opens it.
func TestUnlockFailureNamesMnemonic(t *testing.T) {
t.Parallel()
tests := []struct {
file string // the file removed
inVaultDir bool // the file is the vault's, not the unlocker's
want string // the message before the cause
}{
{
file: "current-unlocker",
inVaultDir: true,
want: "failed to unlock vault: failed to get long-term key: " +
"failed to get current unlocker: " +
"failed to read current unlocker: ",
},
{
file: "priv.age",
want: "failed to unlock vault: failed to get long-term key: " +
"failed to get unlocker identity: " +
"failed to read unlocker private key: ",
},
{
file: "longterm.age",
want: "failed to unlock vault: failed to get long-term key: " +
"failed to read encrypted long-term private key: ",
},
}
for _, tt := range tests {
t.Run(tt.file, func(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
path := filepath.Join(unlockerDir, tt.file)
if tt.inVaultDir {
path = filepath.Join(unlockTestVaultDir, tt.file)
}
require.NoError(t, fs.Remove(path))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
assert.Equal(t, "value", getSecret(t, c, "x"))
})
}
}
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
// error and ends with the advice that the mnemonic still opens the vault.
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
t.Parallel()
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(c.UnlockPassphrase.Destroy)
err := c.GetSecret(discardCmd(), "x")
var noMatch *age.NoIdentityMatchError
require.ErrorAs(t, err, &noMatch)
require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"failed to decrypt unlocker private key: failed to create decryptor: "+
noMatch.Error()+mnemonicAdvice)
}
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
// the vault "work", which is not the current vault, when "work" cannot be
// opened through its current unlocker: the advice names "work" and says to
// select it first, since `secret unlocker add` acts on the current vault.
// The test then follows that advice, and the move succeeds.
func TestMoveUnlockFailureNamesVault(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, c := newUnlockTestCLI(t, fs)
path := filepath.Join(
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
require.NoError(t, fs.Remove(path))
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
"failed to get unlocker identity: failed to read unlocker private key: "+
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
require.NoError(t, c.SelectVault(discardCmd(), "work"))
c.Mnemonic = testMnemonicBuffer(t)
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
c.Mnemonic = nil
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
assert.Equal(t, "value", getSecret(t, c, "y"))
}
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
// terminal, so the passphrase cannot be read. The unlocker was not tried,
// and adding one would need a passphrase read the same way, so the error
// is the cause alone, without the advice to use the mnemonic.
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
t.Parallel()
stateDir := t.TempDir()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
vlt, err := vault.CreateVault(
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
defer value.Destroy()
require.NoError(t, vlt.AddSecret("x", value, false))
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.CombinedOutput()
require.Error(t, err)
assert.Equal(t, "Error: failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+
"failed to read passphrase: cannot read passphrase from non-terminal "+
"stdin (piped input or script). Please set the SB_UNLOCK_PASSPHRASE "+
"environment variable or run interactively\n", string(output))
}
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
// `secret decrypt`, reading the key secret, end with the same advice as
// `secret get` when the vault cannot be opened through its current
// unlocker.
func TestCryptoUnlockFailureNamesMnemonic(t *testing.T) {
t.Parallel()
tests := []struct {
command string
run func(c *cli.Instance) error
}{
{"encrypt", func(c *cli.Instance) error { return c.Encrypt("x", "", "") }},
{"decrypt", func(c *cli.Instance) error { return c.Decrypt("x", "", "") }},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
unlockerDir, c := newUnlockTestCLI(t, fs)
path := filepath.Join(unlockerDir, "priv.age")
require.NoError(t, fs.Remove(path))
err := tt.run(c)
var cause *os.PathError
require.ErrorAs(t, err, &cause)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to get secret value: "+
"failed to unlock vault: failed to get long-term key: "+
"failed to get unlocker identity: "+
"failed to read unlocker private key: "+cause.Error()+
mnemonicAdvice)
})
}
}
// TestMissingCurrentFileNamesVersionCommands checks the error of `secret
// get` when the secret's current file is missing: it keeps the cause, which
// names the file, and ends with the advice that says how to make a version
// current again. The test then follows that advice.
func TestMissingCurrentFileNamesVersionCommands(t *testing.T) {
t.Parallel()
fs := newTwoVaultFs(t)
_, c := newUnlockTestCLI(t, fs)
secretDir := filepath.Join(unlockTestVaultDir, "secrets.d", "x")
path := filepath.Join(secretDir, "current")
require.NoError(t, fs.Remove(path))
err := c.GetSecret(discardCmd(), "x")
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, path, cause.Path)
require.EqualError(t, err, "failed to get current version: "+
"failed to read current version file: "+cause.Error()+versionAdvice)
versions, err := afero.ReadDir(fs, filepath.Join(secretDir, "versions"))
require.NoError(t, err)
require.Len(t, versions, 1)
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
require.NoError(t, c.ListVersions(cmd, "x"))
assert.Contains(t, out.String(), versions[0].Name())
require.NoError(t, c.PromoteVersion(cmd, "x", versions[0].Name()))
assert.Equal(t, "value", getSecret(t, c, "x"))
}
// TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic checks that a vault
// created without a mnemonic, which no mnemonic opens, gets no advice to
// use one: `secret unlocker add passphrase` there fails with the cause
// alone.
func TestUnlockFailureWithoutLongTermKeyNamesNoMnemonic(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "keyless", nil, nil)
require.NoError(t, err)
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(c.UnlockPassphrase.Destroy)
err = c.UnlockersAdd("passphrase", discardCmd())
var cause *os.PathError
require.ErrorAs(t, err, &cause)
require.EqualError(t, err, "failed to get long-term key: "+
"failed to get current unlocker: failed to read current unlocker: "+
cause.Error())
}
+155 -193
View File
@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"maps"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -16,7 +17,6 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -48,7 +48,6 @@ var (
errGPGKeyAlreadyUnlocker = errors.New( errGPGKeyAlreadyUnlocker = errors.New(
"is already added as an unlocker") "is already added as an unlocker")
errUnsupportedUnlockerType = errors.New("unsupported unlocker type") errUnsupportedUnlockerType = errors.New("unsupported unlocker type")
errLastUnlocker = errors.New("refusing to remove last unlocker")
) )
// UnlockerInfo represents unlocker information for display // UnlockerInfo represents unlocker information for display
@@ -230,6 +229,9 @@ func newUnlockerAddCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
unlockerType := args[0] unlockerType := args[0]
// Validate unlocker type // Validate unlocker type
@@ -265,10 +267,11 @@ func newUnlockerRemoveCmd() *cobra.Command {
Use: "remove <unlocker-id>", Use: "remove <unlocker-id>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove an unlocker", Short: "Remove an unlocker",
Long: `Remove an unlocker from the current vault. Cannot remove ` + Long: `Remove an unlocker from the current vault. Asks for ` +
`the last unlocker if the vault has secrets unless --force is ` + `confirmation first, saying whether it is the vault's last ` +
`used. Warning: Without unlockers and without your mnemonic, ` + `unlocker; when stdin is not a terminal, fails unless --force ` +
`vault data will be permanently inaccessible.`, `is given. Warning: Without unlockers and without your ` +
`mnemonic, vault data will be permanently inaccessible.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -284,7 +287,7 @@ func newUnlockerRemoveCmd() *cobra.Command {
} }
cmd.Flags().BoolP("force", "f", false, cmd.Flags().BoolP("force", "f", false,
"Force removal of last unlocker even if vault has secrets") "Remove without asking for confirmation, even the last unlocker")
return cmd return cmd
} }
@@ -311,91 +314,8 @@ func newUnlockerSelectCmd() *cobra.Command {
} }
} }
// unlockerIDFromDir constructs an unlocker of the given metadata type // UnlockersList lists unlockers in the current vault, each under its ID,
// rooted at unlockerDir and returns its ID. Returns "" for unknown types // the name of its directory in unlockers.d
// and, when includeSecureEnclave is false, for secure enclave unlockers.
func unlockerIDFromDir(
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
) string {
// Create the appropriate unlocker instance
var unlocker secret.Unlocker
switch metadata.Type {
case unlockerTypePassphrase:
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
case unlockerTypeKeychain:
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
case unlockerTypePGP:
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
case unlockerTypeSecureEnclave:
if includeSecureEnclave {
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
}
}
if unlocker == nil {
return ""
}
return unlocker.GetID()
}
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
// stored metadata matches the given type and creation time and returns
// the matching unlocker's ID. It returns ("", nil) when the directory is
// readable but holds no match, and a non-nil error when the directory
// itself cannot be read. Callers must distinguish the two: an unreadable
// directory means the unlocker's real ID is unknowable, so the entry has
// to be skipped rather than reported under a synthesized ID.
//
// A metadata file that cannot be read or parsed is skipped without a
// warning: every caller gets metadata from vault.ListUnlockers first,
// which has already warned about that directory.
func findUnlockerIDByMetadata(
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
includeSecureEnclave bool,
) (string, error) {
files, err := afero.ReadDir(fs, unlockersDir)
if err != nil {
return "", fmt.Errorf(
"failed to read unlockers directory %s: %w", unlockersDir, err,
)
}
for _, file := range files {
if !file.IsDir() {
continue
}
unlockerDir := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
// Check if this is the right unlocker by comparing metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
continue
}
var diskMetadata secret.UnlockerMetadata
err = json.Unmarshal(metadataBytes, &diskMetadata)
if err != nil {
continue
}
// Match by type and creation time
if diskMetadata.Type == metadata.Type &&
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
includeSecureEnclave), nil
}
}
return "", nil
}
// UnlockersList lists unlockers in the current vault
func (cli *Instance) UnlockersList(jsonOutput bool) error { func (cli *Instance) UnlockersList(jsonOutput bool) error {
// Get current vault // Get current vault
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
@@ -411,58 +331,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
currentUnlockerID = currentUnlocker.GetID() currentUnlockerID = currentUnlocker.GetID()
} }
// Get the metadata first unlockerMetadata, err := vlt.ListUnlockers()
unlockerMetadataList, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return err return err
} }
// Load actual unlocker objects to get the proper IDs
var unlockers []UnlockerInfo var unlockers []UnlockerInfo
for _, metadata := range unlockerMetadataList { for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
// Create unlocker instance to get the proper ID metadata := unlockerMetadata[unlockerID]
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory while listing unlockers",
"error", err)
continue unlockers = append(unlockers, UnlockerInfo{
} ID: unlockerID,
// Find the unlocker directory by type and created time
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
unlockerID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, metadata, true,
)
if err != nil {
secret.Warn("Could not read unlockers directory, skipping unlocker",
"unlockers_dir", unlockersDir, "error", err)
continue
}
// Get the proper ID using the unlocker's ID() method
var properID string
if unlockerID != "" {
properID = unlockerID
} else {
// Generate ID as fallback
properID = fmt.Sprintf("%s-%s",
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
secret.Warn("Could not create unlocker instance, using fallback ID",
"fallback_id", properID, "type", metadata.Type)
}
unlockerInfo := UnlockerInfo{
ID: properID,
Type: metadata.Type, Type: metadata.Type,
CreatedAt: metadata.CreatedAt, CreatedAt: metadata.CreatedAt,
Flags: metadata.Flags, Flags: metadata.Flags,
IsCurrent: properID == currentUnlockerID, IsCurrent: unlockerID == currentUnlockerID,
} })
unlockers = append(unlockers, unlockerInfo)
} }
if jsonOutput { if jsonOutput {
@@ -580,19 +465,19 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// For passphrase unlockers, we don't need the vault to be unlocked // For passphrase unlockers, we don't need the vault to be unlocked
// The CreatePassphraseUnlocker method will handle getting the // The CreatePassphraseUnlocker method will handle getting the
// long-term key // long-term key
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Check if passphrase is set in environment variable // The new unlocker gets the passphrase from the environment, which also
var passphraseBuffer *memguard.LockedBuffer // unlocks the current passphrase unlocker, else the one entered here
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { passphraseBuffer := cli.UnlockPassphrase
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase)) if passphraseBuffer == nil {
} else {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
defer passphraseBuffer.Destroy()
} }
defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil { if err != nil {
@@ -601,8 +486,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())
// Auto-select the newly created unlocker // CreatePassphraseUnlocker has already made it the current unlocker
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID()) cmd.Printf("Automatically selected as current unlocker\n")
return nil return nil
} }
@@ -613,7 +498,8 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
return errKeychainMacOSOnly return errKeychainMacOSOnly
} }
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir) keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err) return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
} }
@@ -643,7 +529,8 @@ func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
return errSecureEnclaveMacOSOnly return errSecureEnclaveMacOSOnly
} }
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir) seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err) return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
} }
@@ -685,16 +572,15 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return fmt.Errorf("failed to get current vault: %w", err) return fmt.Errorf("failed to get current vault: %w", err)
} }
// Resolve the GPG key ID to its fingerprint // Resolve the GPG key ID to its fingerprint, once: the duplicate check
// and the new unlocker's metadata both use this result
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID) fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil { if err != nil {
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err) return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
} }
// Check if this GPG key is already added // Check if this GPG key is already added
expectedID := "pgp-" + fingerprint exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
exists, err := cli.checkUnlockerExists(vlt, expectedID)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"could not check whether GPG key %s is already an unlocker: %w", "could not check whether GPG key %s is already an unlocker: %w",
@@ -706,7 +592,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, gpgKeyID) pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
return err return err
} }
@@ -720,62 +607,123 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return nil return nil
} }
// UnlockersRemove removes an unlocker, holding the state directory lock // UnlockersRemove removes an unlocker from the current vault, after asking
// while removeUnlocker runs // the user to confirm unless force is set.
func (cli *Instance) UnlockersRemove( func (cli *Instance) UnlockersRemove(
unlockerID string, force bool, cmd *cobra.Command, unlockerID string, force bool, cmd *cobra.Command,
) error { ) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found unlockerToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findUnlockerToRemove(unlockerID)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
return cli.removeUnlocker(unlockerID, force, cmd) return cli.removeUnlocker(unlockerID, found, cmd)
} }
// removeUnlocker removes an unlocker with safety checks // unlockerToRemove is what removing an unlocker removes, as
func (cli *Instance) removeUnlocker( // findUnlockerToRemove found it.
unlockerID string, force bool, cmd *cobra.Command, type unlockerToRemove struct {
) error { vlt *vault.Vault
// Get current vault // last is set when the unlocker counts as the vault's last one, and
// secrets is then the number of secrets in the vault.
last bool
secrets int
// question names what is removed, for the user to confirm.
question string
}
// findUnlockerToRemove checks that the current vault has the unlocker and
// finds whether it is the vault's last one.
func (cli *Instance) findUnlockerToRemove(
unlockerID string,
) (unlockerToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return unlockerToRemove{}, err
} }
// Get list of unlockers exists, err := vlt.HasUnlocker(unlockerID)
if err != nil {
return unlockerToRemove{}, err
}
if !exists {
return unlockerToRemove{}, fmt.Errorf("unlocker with ID %s %w",
unlockerID, vault.ErrUnlockerNotFound)
}
// Get list of unlockers. It leaves out a directory whose metadata file
// is missing or cannot be checked for, read or parsed.
unlockers, err := vlt.ListUnlockers() unlockers, err := vlt.ListUnlockers()
if err != nil { if err != nil {
return fmt.Errorf("failed to list unlockers: %w", err) return unlockerToRemove{},
fmt.Errorf("failed to list unlockers: %w", err)
}
vaultDir, err := vlt.GetDirectory()
if err != nil {
return unlockerToRemove{},
fmt.Errorf("failed to get vault directory: %w", err)
}
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
found := unlockerToRemove{
vlt: vlt,
question: fmt.Sprintf("Permanently remove unlocker '%s' from vault "+
"'%s'? It is not the vault's last unlocker.",
unlockerID, vlt.GetName()),
} }
// Check if we're removing the last unlocker
if len(unlockers) == 1 { if len(unlockers) == 1 {
// Check if vault has secrets _, found.last = unlockers[unlockerID]
numSecrets, err := vlt.NumSecrets()
if err != nil {
return fmt.Errorf("failed to count secrets: %w", err)
}
if numSecrets > 0 && !force {
cmd.Println("ERROR: Cannot remove the last unlocker when the " +
"vault contains secrets.")
cmd.Println("WARNING: Without unlockers, you MUST have your " +
"mnemonic phrase to decrypt the vault.")
cmd.Println("If you want to proceed anyway, use --force")
return errLastUnlocker
}
if numSecrets > 0 && force {
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
"have your mnemonic phrase to access this vault again!")
}
} }
// Remove the unlocker // unlockerID may instead name a directory left out of the list. If its
err = vlt.RemoveUnlocker(unlockerID) // metadata file is missing or corrupt it is not a working unlocker, so
// removing it never removes the last one. If the file cannot be checked
// for or read, the unlocker may be the only working one, so removing it
// counts as removing the last unlocker.
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
found.last = true
}
if found.last {
found.secrets, err = vlt.NumSecrets()
if err != nil {
return unlockerToRemove{},
fmt.Errorf("failed to count secrets: %w", err)
}
found.question = fmt.Sprintf("Permanently remove unlocker '%s', "+
"the last unlocker of vault '%s', which holds %d secret(s)? "+
"Without an unlocker the vault opens only with its mnemonic.",
unlockerID, vlt.GetName(), found.secrets)
}
return found, nil
}
// removeUnlocker removes the unlocker that findUnlockerToRemove found. The
// caller holds the state directory lock.
func (cli *Instance) removeUnlocker(
unlockerID string, found unlockerToRemove, cmd *cobra.Command,
) error {
if found.last && found.secrets > 0 {
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
"have your mnemonic phrase to access this vault again!")
}
err := found.vlt.RemoveUnlocker(unlockerID)
if err != nil { if err != nil {
return err return err
} }
@@ -785,6 +733,20 @@ func (cli *Instance) removeUnlocker(
return nil return nil
} }
// metadataUnreadable reports whether checking for or reading the metadata
// file in the unlocker directory unlockerDir fails. A missing file is not
// a failure.
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
exists, err := afero.Exists(fs, metadataPath)
if err == nil && exists {
_, err = afero.ReadFile(fs, metadataPath)
}
return err != nil
}
// UnlockerSelect selects an unlocker as current // UnlockerSelect selects an unlocker as current
func (cli *Instance) UnlockerSelect(unlockerID string) error { func (cli *Instance) UnlockerSelect(unlockerID string) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir) release, err := vault.LockStateDir(cli.fs, cli.stateDir)
@@ -802,16 +764,16 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID) return vlt.SelectUnlocker(unlockerID)
} }
// checkUnlockerExists reports whether the vault already has an unlocker // pgpUnlockerExists reports whether the vault already has a PGP unlocker
// with the given ID. It returns an error, and no answer, when unlockers.d // for the GPG key with the given fingerprint. It returns an error, and no
// or an unlocker's metadata file cannot be read; the caller must then not // answer, when unlockers.d or an unlocker's metadata file cannot be read;
// create the unlocker. It reads unlockers.d itself because // the caller must then not create the unlocker. It reads unlockers.d itself
// vault.ListUnlockers skips an unlocker it cannot read, which suits // because vault.ListUnlockers skips an unlocker it cannot read, which suits
// `unlocker list` but not this check: the skipped unlocker may be the // `unlocker list` but not this check: the skipped unlocker may be the
// duplicate. A directory whose metadata file is missing or corrupt is not // duplicate. A directory whose metadata file is missing or corrupt is not
// a working unlocker and is passed over. // a working unlocker and is passed over.
func (cli *Instance) checkUnlockerExists( func (cli *Instance) pgpUnlockerExists(
vlt *vault.Vault, unlockerID string, vlt *vault.Vault, fingerprint string,
) (bool, error) { ) (bool, error) {
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
@@ -850,14 +812,14 @@ func (cli *Instance) checkUnlockerExists(
) )
} }
var metadata secret.UnlockerMetadata var metadata secret.PGPUnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata) err = json.Unmarshal(metadataBytes, &metadata)
if err != nil { if err != nil {
continue continue
} }
if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID { if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
return true, nil return true, nil
} }
} }
+105
View File
@@ -0,0 +1,105 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
const unknownTestGPGUserID = "not-in-keyring@example.com"
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
const (
addTestSecretName = "api-key"
addTestSecretValue = "value"
)
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
// with a passphrase unlocker, getting the vault's long-term key from the
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
// then reads a secret with neither the mnemonic nor the passphrase given, so
// through the new unlocker, which the add selects.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlocker(t *testing.T) {
newTestGPGKey(t)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Cleanup(passphrase.Destroy)
tests := []struct {
name string
// mnemonic is the mnemonic given while the unlocker is added, or nil.
mnemonic *memguard.LockedBuffer
}{
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
{"long-term key from the current unlocker", nil},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
err = vlt.AddSecret(addTestSecretName,
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
require.NoError(t, err)
_, err = vlt.CreatePassphraseUnlocker(
memguard.NewBufferFromBytes([]byte(testPassphrase)))
require.NoError(t, err)
instance, cmd := newTestInstance(fs)
instance.Mnemonic = test.mnemonic
instance.UnlockPassphrase = passphrase
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
current, err := reopened.GetCurrentUnlocker()
require.NoError(t, err)
assert.Equal(t, unlockerTypePGP, current.GetType())
value, err := reopened.GetSecret(addTestSecretName)
require.NoError(t, err)
defer value.Destroy()
assert.Equal(t, addTestSecretValue, value.String())
})
}
}
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
// the keyring does not hold fails at looking up the key's fingerprint and
// leaves no new unlocker directory. The error must come from the lookup: a
// lookup moved after anything is written would also come after getting the
// vault's long-term key, which fails first here: this vault's unlockers hold
// no keys.
//
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
newTestGPGKey(t)
base := newListTestVault(t, 1)
instance, cmd := newTestInstance(base)
cmd.Flags().String("keyid", unknownTestGPGUserID, "")
err := instance.addPGPUnlocker(cmd)
require.ErrorContains(t, err, "failed to resolve GPG key fingerprint")
assertDirEntries(t, base,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
listTestUnlockerDirOne)
}
+191
View File
@@ -0,0 +1,191 @@
// Corrupt Unlocker Tests
//
// `secret unlocker select` and `secret unlocker remove` find an unlocker
// by its ID. These tests give the first unlocker, which sorts before the
// one the commands act on, metadata that is not JSON, and check that the
// commands step past it, and that it can itself be removed by its
// directory name, which `secret unlocker list` names in its warning, as can
// one with no metadata file. A last test checks that an unlocker whose
// metadata file cannot be read counts as the last unlocker when it is
// removed by its directory name.
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"path/filepath"
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newCorruptUnlockerVault returns the two-unlocker test vault with the
// metadata of the first unlocker replaced by text that is not JSON.
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
t.Helper()
fs := newListTestVault(t, 2)
require.NoError(t, afero.WriteFile(fs,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
listTestUnlockerDirOne, listTestMetadataFileName),
[]byte("not json"), listTestFilePerm))
return fs
}
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
// can be selected, and that the corrupt one, having no type to be used as,
// cannot be selected by its directory name.
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
instance, _ := newTestInstance(fs)
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
current, err := afero.ReadFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
require.NoError(t, err)
assert.Equal(t, listTestUnlockerDirTwo, string(current))
err = instance.UnlockerSelect(listTestUnlockerDirOne)
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
}
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
// counts as the vault's last one, since the corrupt unlocker cannot unlock
// the vault, and that the corrupt one, removed by its directory name, does
// not. Either is removed once the user confirms.
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel()
tests := []struct {
name string
unlockerID string
wantLast bool
wantEntries []string
}{
{
name: "the other unlocker",
unlockerID: listTestUnlockerDirTwo,
wantLast: true,
wantEntries: []string{listTestUnlockerDirOne},
},
{
name: "the corrupt unlocker by its directory name",
unlockerID: listTestUnlockerDirOne,
wantEntries: []string{listTestUnlockerDirTwo},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
fs := newCorruptUnlockerVault(t)
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
instance, cmd := newTestInstance(fs)
found, err := instance.findUnlockerToRemove(tt.unlockerID)
require.NoError(t, err)
assert.Equal(t, tt.wantLast, found.last)
instance.terminal = strings.NewReader("y\n")
require.NoError(t, instance.UnlockersRemove(tt.unlockerID, false, cmd))
assertDirEntries(t, fs,
filepath.Join(testVaultDir(listTestVaultName),
listTestUnlockersDirName),
tt.wantEntries...)
})
}
}
// TestUnlockerRemoveWithoutMetadata asserts that a partial unlocker
// directory, one with no metadata file, removed by its directory name from
// a vault with secrets, does not count as the vault's last unlocker, since
// it cannot unlock the vault, so the question says it is not. It is
// removed once the user confirms.
func TestUnlockerRemoveWithoutMetadata(t *testing.T) {
t.Parallel()
fs := newListTestVault(t, 2)
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
require.NoError(t, fs.Remove(filepath.Join(
unlockersDir, listTestUnlockerDirOne, listTestMetadataFileName)))
writeTestSecret(t, fs, vaultDir)
instance, cmd := newTestInstance(fs)
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.NoError(t, err)
assert.False(t, found.last)
assert.Contains(t, found.question, "not the vault's last unlocker")
instance.terminal = strings.NewReader("y\n")
require.NoError(t, instance.UnlockersRemove(listTestUnlockerDirOne, false, cmd))
assertDirEntries(t, fs, unlockersDir, listTestUnlockerDirTwo)
}
// TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
// of a vault with secrets, removed by its directory name when its metadata
// file cannot be checked for or read, counts as the vault's last unlocker,
// so the question warns that it is: listing leaves it out, but it may
// still be the vault's only working unlocker. It is then removed. The
// state directory lock refuses the failing filesystem, so the test calls
// findUnlockerToRemove and removeUnlocker, which UnlockersRemove runs to
// make its checks and, once it holds the lock, to remove the unlocker.
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
t.Parallel()
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
listTestMetadataFileName)
tests := []struct {
name string
wrap func(base afero.Fs) afero.Fs
}{
{
name: "checking for the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
},
},
{
name: "reading the file fails",
wrap: func(base afero.Fs) afero.Fs {
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(tt.wrap(base))
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
require.NoError(t, err)
assert.True(t, found.last)
assert.Contains(t, found.question, "the last unlocker")
require.NoError(t,
instance.removeUnlocker(listTestUnlockerDirOne, found, cmd))
assertDirEntries(t, base, unlockersDir)
})
}
}
+79
View File
@@ -0,0 +1,79 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
// side by side whose metadata is the same, creation time included, as
// copying an unlocker directory leaves them. It asserts that `unlocker
// list` and the shell completion of `unlocker select` and `unlocker remove`
// give each its own ID, and that each is selected and removed by its ID
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
// get their IDs the same way.
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
vaultDir := testVaultDir(listTestVaultName)
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
dirNames := []string{
"passphrase-2026-10-04.12.30.00.000000000",
"passphrase-2026-10-04.12.30.00.000000000-copy",
}
metadata, err := json.Marshal(secret.UnlockerMetadata{
Type: unlockerTypePassphrase,
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
})
require.NoError(t, err)
for _, dirName := range dirNames {
dir := filepath.Join(unlockersDir, dirName)
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
require.NoError(t, afero.WriteFile(fs,
filepath.Join(dir, listTestMetadataFileName), metadata,
listTestFilePerm))
}
listed := listUnlockersJSON(t, fs)
require.Len(t, listed, len(dirNames))
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
nil, nil, "")
assert.Equal(t, dirNames, completed)
instance, cmd := newTestInstance(fs)
for i, unlocker := range listed {
assert.Equal(t, dirNames[i], unlocker.ID)
require.NoError(t, instance.UnlockerSelect(unlocker.ID))
current, err := afero.ReadFile(fs,
filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
assert.Equal(t, dirNames[i], string(current))
}
// The second one first: an ID both shared would remove the first one
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
assertDirEntries(t, fs, unlockersDir, dirNames[0])
require.NoError(t, instance.UnlockersRemove(listed[0].ID, true, cmd))
assertDirEntries(t, fs, unlockersDir)
}
+25 -80
View File
@@ -1,25 +1,13 @@
// Unlocker List Tests // Unlocker List Tests
// //
// Tests for `secret unlocker list` behavior when the unlockers.d directory, // Tests for `secret unlocker list` behavior when an unlocker's metadata
// or an unlocker's metadata in it, cannot be read while the listing is // cannot be read or used:
// being rendered:
// //
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
// still listed, with its real ID and its current-unlocker marker,
// when a later entry's scan fails.
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt // - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
// metadata does not stop the others from being listed. // metadata does not stop the others from being listed.
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata // - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
// file cannot be checked for or read is left out, and the other is // file cannot be checked for or read is left out, and the other is
// still listed. // still listed.
//
// The listing resolves each unlocker's real ID by rescanning unlockers.d
// after the vault has already enumerated it. If that rescan fails the ID
// is unknowable, so the entry must be skipped: a synthesized ID matches
// no `unlocker remove` or `unlocker select` argument and would also
// suppress the current-unlocker marker.
//nolint:testpackage // white-box test of unexported internals //nolint:testpackage // white-box test of unexported internals
package cli package cli
@@ -48,18 +36,16 @@ const (
// listTestVaultName is the name of that synthetic vault. // listTestVaultName is the name of that synthetic vault.
listTestVaultName = "default" listTestVaultName = "default"
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP // listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
// unlocker's metadata. The unlocker's real ID is derived from it, and // in the PGP unlockers' metadata.
// differs from the timestamp-derived fallback ID.
listTestGPGKeyID = "DEADBEEFDEADBEEF" listTestGPGKeyID = "DEADBEEFDEADBEEF"
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker // listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
// directory names under unlockers.d. // directory names under unlockers.d, and so the unlockers' IDs.
listTestUnlockerDirOne = "host-pgp-2026-08-09" listTestUnlockerDirOne = "host-pgp-2026-08-09"
listTestUnlockerDirTwo = "host-pgp-2026-08-10" listTestUnlockerDirTwo = "host-pgp-2026-08-10"
// listTestUnlockersDirName is the directory the listing rescans to // listTestUnlockersDirName is the directory holding the unlockers.
// resolve unlocker IDs.
listTestUnlockersDirName = "unlockers.d" listTestUnlockersDirName = "unlockers.d"
// listTestMetadataFileName is the per-unlocker metadata file name. // listTestMetadataFileName is the per-unlocker metadata file name.
@@ -74,25 +60,16 @@ const (
// a successful open of unlockers.d. // a successful open of unlockers.d.
var errUnlockersDirUnreadable = errors.New("permission denied") var errUnlockersDirUnreadable = errors.New("permission denied")
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened // unlockersDirFailFs fails every open of unlockers.d, as when the
// successfully openBudget times. This reproduces the directory becoming // directory cannot be read.
// unreadable (permission change, partially restored backup, EIO) between
// the vault's own enumeration and the per-entry rescan that resolves
// unlocker IDs.
type unlockersDirFailFs struct { type unlockersDirFailFs struct {
afero.Fs afero.Fs
openBudget int
opens int
} }
//nolint:ireturn // afero.File is the interface required by afero.Fs //nolint:ireturn // afero.File is the interface required by afero.Fs
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) { func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
if filepath.Base(name) == listTestUnlockersDirName { if filepath.Base(name) == listTestUnlockersDirName {
f.opens++ return nil, errUnlockersDirUnreadable
if f.opens > f.openBudget {
return nil, errUnlockersDirUnreadable
}
} }
//nolint:wrapcheck // test double must return the wrapped Fs error as-is //nolint:wrapcheck // test double must return the wrapped Fs error as-is
@@ -142,8 +119,8 @@ func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
return f.Fs.Stat(name) return f.Fs.Stat(name)
} }
// writePGPUnlocker writes a PGP unlocker directory with metadata that // writePGPUnlocker writes a PGP unlocker directory named dirName, with
// yields the real ID "pgp-<keyID>". // metadata recording the GPG key ID keyID.
func writePGPUnlocker( func writePGPUnlocker(
t *testing.T, fs afero.Fs, unlockersDir, dirName string, t *testing.T, fs afero.Fs, unlockersDir, dirName string,
createdAt time.Time, keyID string, createdAt time.Time, keyID string,
@@ -224,44 +201,6 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
return decoded.Unlockers return decoded.Unlockers
} }
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
// which becomes unreadable after the vault enumerated it produces no rows,
// rather than rows carrying fabricated fallback IDs.
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 1)
// Budget of one: the vault's own ListUnlockers scan succeeds, the
// per-entry rescan that resolves the ID fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
unlockers := listUnlockersJSON(t, fs)
assert.Empty(t, unlockers,
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
}
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
// entry survives with its real ID and current-unlocker marker when a later
// entry's rescan fails.
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
t.Parallel()
base := newListTestVault(t, 2)
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
// the second entry's rescan fails.
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
unlockers := listUnlockersJSON(t, fs)
require.Len(t, unlockers, 1,
"only the entry whose directory was readable may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
"the surviving row must carry the real unlocker ID")
assert.True(t, unlockers[0].IsCurrent,
"the current-unlocker marker must survive the skip")
}
// TestUnlockersListReadableEntriesAreListed is the control case: with a // TestUnlockersListReadableEntriesAreListed is the control case: with a
// fully readable unlockers.d every entry is listed with its real ID. // fully readable unlockers.d every entry is listed with its real ID.
func TestUnlockersListReadableEntriesAreListed(t *testing.T) { func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
@@ -272,20 +211,21 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
unlockers := listUnlockersJSON(t, base) unlockers := listUnlockersJSON(t, base)
require.Len(t, unlockers, 2) require.Len(t, unlockers, 2)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID) assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID) assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
assert.True(t, unlockers[0].IsCurrent) assert.True(t, unlockers[0].IsCurrent)
assert.False(t, unlockers[1].IsCurrent) assert.False(t, unlockers[1].IsCurrent)
} }
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with // TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
// corrupt metadata does not stop the listing. Metadata that is not JSON // corrupt metadata does not stop the listing. Metadata that is not JSON
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists // leaves that unlocker out; PGP metadata without a usable GPG key ID, and
// it as "pgp-unknown". The healthy unlocker is listed with its real ID. // metadata of an unknown type, are still listed, under the directory name
// like any other. The healthy unlocker is listed with its real ID.
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) { func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
t.Parallel() t.Parallel()
healthyID := "pgp-" + listTestGPGKeyID + "A" healthyID := listTestUnlockerDirOne
tests := []struct { tests := []struct {
name string name string
@@ -300,12 +240,17 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
{ {
name: "GPG key ID of the wrong type", name: "GPG key ID of the wrong type",
metadata: `{"type": "pgp", "gpgKeyId": 42}`, metadata: `{"type": "pgp", "gpgKeyId": 42}`,
wantIDs: []string{healthyID, "pgp-unknown"}, wantIDs: []string{healthyID, listTestUnlockerDirTwo},
}, },
{ {
name: "GPG key ID missing", name: "GPG key ID missing",
metadata: `{"type": "pgp"}`, metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, "pgp-unknown"}, wantIDs: []string{healthyID, listTestUnlockerDirTwo},
},
{
name: "unknown type",
metadata: `{"type": "unknown"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
}, },
} }
@@ -371,7 +316,7 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
require.Len(t, unlockers, 1, require.Len(t, unlockers, 1,
"only the unlocker with usable metadata may be listed") "only the unlocker with usable metadata may be listed")
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID, assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
"the listed row must carry the real unlocker ID") "the listed row must carry the real unlocker ID")
}) })
} }
+46 -10
View File
@@ -2,15 +2,18 @@
// //
// The checks that guard adding a PGP unlocker (is this key already an // The checks that guard adding a PGP unlocker (is this key already an
// unlocker?), removing the last unlocker and removing a vault (does the // unlocker?), removing the last unlocker and removing a vault (does the
// vault hold secrets?), and importing a mnemonic (does the vault already // vault hold secrets?), removing a secret (how many versions does it
// have a long-term key?) each look at the vault on disk before acting. // have?), and importing a mnemonic (does the vault already have a
// long-term key?) each look at the vault on disk before acting.
// When that look fails they must refuse to act, not read the failure as // When that look fails they must refuse to act, not read the failure as
// "nothing there" and go ahead. // "nothing there" and go ahead.
// //
// The tests make the look fail with a wrapper around the in-memory // The tests make the look fail with a wrapper around the in-memory
// filesystem, which the state directory lock refuses. So they call the // filesystem, which the state directory lock refuses. So they call the
// function each command runs once it holds the lock, such as removeVault // function each command runs once it holds the lock, such as addPGPUnlocker
// for RemoveVault. // for UnlockersAdd, or, for a removal, the function that makes its checks,
// such as findVaultToRemove for RemoveVault, which runs again under the
// lock before anything is removed, with --force or without.
//nolint:testpackage // white-box test of unexported internals //nolint:testpackage // white-box test of unexported internals
package cli package cli
@@ -122,7 +125,8 @@ func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
} }
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key // newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
// without a passphrase there, and returns the key's fingerprint. // without a passphrase there, with a subkey for encryption, and returns the
// key's fingerprint.
func newTestGPGKey(t *testing.T) string { func newTestGPGKey(t *testing.T) string {
t.Helper() t.Helper()
@@ -151,6 +155,14 @@ func newTestGPGKey(t *testing.T) string {
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID) fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
require.NoError(t, err) require.NoError(t, err)
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
"--pinentry-mode", "loopback", "--passphrase", "",
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
).CombinedOutput()
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
output)
return fingerprint return fingerprint
} }
@@ -276,10 +288,9 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
base := newListTestVault(t, 1) base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(&statFailFs{Fs: base, path: path}) instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
err := instance.removeUnlocker( _, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
"pgp-"+listTestGPGKeyID+"A", false, cmd)
require.ErrorIs(t, err, errStatFailed) require.ErrorIs(t, err, errStatFailed)
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne) assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
@@ -323,9 +334,9 @@ func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
base := newListTestVault(t, 1) base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, cmd := newTestInstance(tt.failFs(base)) instance, _ := newTestInstance(tt.failFs(base))
err := instance.removeVault(cmd, unreadableTestOtherVault, false) _, err := instance.findVaultToRemove(unreadableTestOtherVault)
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, tt.wantErr)
@@ -336,6 +347,31 @@ func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
} }
} }
// TestRemoveSecretAbortsWhenVersionsUnreadable asserts that a secret is
// kept when its versions directory exists but cannot be listed, so that
// the question cannot say how many versions would be removed.
func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
t.Parallel()
secretDir := filepath.Join(testVaultDir(listTestVaultName),
unreadableTestSecretsDirName, unreadableTestSecretName)
versionsDir := filepath.Join(secretDir, "versions")
base := newListTestVault(t, 1)
writeTestSecret(t, base, testVaultDir(listTestVaultName))
require.NoError(t, base.MkdirAll(versionsDir, listTestDirPerm))
instance, _ := newTestInstance(&openFailFs{Fs: base, path: versionsDir})
_, err := instance.findSecretToRemove(unreadableTestSecretName)
require.ErrorIs(t, err, errOpenFailed)
exists, err := afero.DirExists(base, secretDir)
require.NoError(t, err)
assert.True(t, exists, "the secret must not be removed")
}
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import // TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
// stops when whether the vault already has a long-term key cannot be // stops when whether the vault already has a long-term key cannot be
// determined. // determined.
+46
View File
@@ -0,0 +1,46 @@
package cli_test
import (
"strings"
"testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// usageHeading starts the usage text cobra prints after an error.
const usageHeading = "Usage:"
// A command called wrongly gets usage after its error; a command that
// fails while running gets its error alone. Either way the command fails
// and its error is shown exactly once.
//
//nolint:paralleltest // executes the CLI in-process and sets the environment
func TestUsageOnlyForCallErrors(t *testing.T) {
// No vault in the state directory, so `get x` fails while running.
env := map[string]string{secret.EnvStateDir: t.TempDir()}
tests := []struct {
call string
wantUsage bool
}{
{call: "get", wantUsage: true},
{call: "get x y", wantUsage: true},
{call: "get --no-such-flag x", wantUsage: true},
{call: "generate secret x --length abc", wantUsage: true},
{call: "import x", wantUsage: true},
{call: "get x", wantUsage: false},
}
for _, tt := range tests {
output, err := cli.ExecuteCommandInProcess(strings.Fields(tt.call), "", env)
require.Error(t, err, "%q should fail", tt.call)
assert.Equal(t, 1, strings.Count(output, err.Error()),
"%q should show its error once:\n%s", tt.call, output)
assert.Equal(t, tt.wantUsage, strings.Contains(output, usageHeading),
"usage shown for %q:\n%s", tt.call, output)
}
}
+132 -148
View File
@@ -5,7 +5,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"os"
"path/filepath" "path/filepath"
"slices" "slices"
"strings" "strings"
@@ -32,8 +31,6 @@ var (
errPassphraseEnvNotSet = errors.New( errPassphraseEnvNotSet = errors.New(
"SB_UNLOCK_PASSPHRASE environment variable not set") "SB_UNLOCK_PASSPHRASE environment variable not set")
errCannotRemoveLastVault = errors.New("cannot remove the last vault") errCannotRemoveLastVault = errors.New("cannot remove the last vault")
errVaultContainsSecrets = errors.New(
"contains secrets; use --force to remove")
) )
func newVaultCmd() *cobra.Command { func newVaultCmd() *cobra.Command {
@@ -85,6 +82,9 @@ func newVaultCreateCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.CreateVault(cmd, args[0]) return cli.CreateVault(cmd, args[0])
}, },
} }
@@ -136,6 +136,9 @@ func newVaultImportCmd() *cobra.Command {
return fmt.Errorf("failed to initialize CLI: %w", err) return fmt.Errorf("failed to initialize CLI: %w", err)
} }
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.VaultImport(cmd, vaultName) return cli.VaultImport(cmd, vaultName)
}, },
} }
@@ -151,9 +154,12 @@ func newVaultRemoveCmd() *cobra.Command {
Use: "remove <name>", Use: "remove <name>",
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a vault", Short: "Remove a vault",
Long: `Remove a vault. Requires --force if the vault contains ` + Long: `Remove a vault and all its secrets. Asks for ` +
`secrets. Will automatically switch to another vault if ` + `confirmation first, naming how many secrets the vault ` +
`removing the currently selected one.`, `holds; when stdin is not a terminal, fails unless --force ` +
`is given. Will automatically switch to another vault if ` +
`removing the currently selected one. The last vault ` +
`cannot be removed.`,
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
@@ -168,7 +174,8 @@ func newVaultRemoveCmd() *cobra.Command {
}, },
} }
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets") cmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation, even a vault that contains secrets")
return cmd return cmd
} }
@@ -228,28 +235,14 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return nil return nil
} }
// setMnemonicEnv sets the mnemonic environment variable and returns a // resolvePassphrase returns the unlock passphrase from the environment,
// function that restores the previous value // cli.UnlockPassphrase, or prompts the user for it with confirmation. The
func setMnemonicEnv(mnemonicStr string) func() { // returned cleanup function must be deferred by the caller.
originalMnemonic := os.Getenv(secret.EnvMnemonic) func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr) if cli.UnlockPassphrase != nil {
return func() {
if originalMnemonic != "" {
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
} else {
_ = os.Unsetenv(secret.EnvMnemonic)
}
}
}
// resolvePassphrase returns the unlock passphrase from the environment or
// prompts the user for it with confirmation
func resolvePassphrase() (*memguard.LockedBuffer, error) {
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil return cli.UnlockPassphrase, func() {}, nil
} }
secret.Debug("Prompting user for unlock passphrase") secret.Debug("Prompting user for unlock passphrase")
@@ -257,10 +250,10 @@ func resolvePassphrase() (*memguard.LockedBuffer, error) {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read passphrase: %w", err) return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
} }
return passphraseBuffer, nil return passphraseBuffer, passphraseBuffer.Destroy, nil
} }
// CreateVault creates a new vault // CreateVault creates a new vault
@@ -273,30 +266,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
} }
defer release() defer release()
// Get or prompt for mnemonic mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
var mnemonicStr string if err != nil {
return err
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
secret.Debug("Using mnemonic from environment variable")
mnemonicStr = envMnemonic
} else {
secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadPassphrase(
"Enter your BIP39 mnemonic phrase: ")
if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err)
return fmt.Errorf("failed to read mnemonic: %w", err)
}
defer mnemonicBuffer.Destroy()
mnemonicStr = mnemonicBuffer.String()
fmt.Fprintln(os.Stderr) // Add newline after hidden input
} }
defer cleanupMnemonic()
mnemonicStr := mnemonic.String()
if mnemonicStr == "" { if mnemonicStr == "" {
return errMnemonicEmpty return errMnemonicEmpty
} }
@@ -311,50 +287,32 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
// Ask for the unlocker passphrase before creating the vault, so that // Ask for the unlocker passphrase before creating the vault, so that
// stopping at the prompt leaves no vault without an unlocker behind // stopping at the prompt leaves no vault without an unlocker behind
passphraseBuffer, err := resolvePassphrase() passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
if err != nil { if err != nil {
return err return err
} }
defer passphraseBuffer.Destroy() defer cleanupPassphrase()
// Set mnemonic in environment for CreateVault to use // Create the vault with its passphrase unlocker
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr) vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
defer restoreMnemonicEnv() mnemonic, passphraseBuffer)
// Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
if err != nil { if err != nil {
return err return err
} }
// Get the vault metadata to retrieve the derivation index ltIdentity, err := vlt.GetOrDeriveLongTermKey()
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to load vault metadata: %w", err) return fmt.Errorf("failed to get long-term key: %w", err)
} }
// Derive the long-term key using the same index that CreateVault used unlocker, err := vlt.GetCurrentUnlocker()
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
if err != nil { if err != nil {
return fmt.Errorf("failed to derive long-term key from mnemonic: %w", err) return err
}
// Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity)
// Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
return fmt.Errorf("failed to create unlocker: %w", err)
} }
cmd.Printf("Created vault '%s'\n", vlt.GetName()) cmd.Printf("Created vault '%s'\n", vlt.GetName())
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String()) cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID()) cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
return nil return nil
} }
@@ -412,11 +370,12 @@ func (cli *Instance) vaultImportPreflight(
} }
// Get mnemonic from environment // Get mnemonic from environment
mnemonic := os.Getenv(secret.EnvMnemonic) if cli.Mnemonic == nil {
if mnemonic == "" {
return "", "", "", errMnemonicEnvNotSet return "", "", "", errMnemonicEnvNotSet
} }
mnemonic := cli.Mnemonic.String()
// Validate the mnemonic // Validate the mnemonic
mnemonicWords := strings.Fields(mnemonic) mnemonicWords := strings.Fields(mnemonic)
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
@@ -462,6 +421,11 @@ func updateVaultImportMetadata(
// VaultImport imports a mnemonic into a specific vault, holding the state // VaultImport imports a mnemonic into a specific vault, holding the state
// directory lock while importMnemonic runs // directory lock while importMnemonic runs
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error { func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
err := vault.ValidateVaultName(vaultName)
if err != nil {
return err
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir) release, err := vault.LockStateDir(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return err
@@ -534,17 +498,13 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
} }
// Get passphrase from environment variable // Get passphrase from environment variable
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase) passphraseBuffer := cli.UnlockPassphrase
if passphraseStr == "" { if passphraseBuffer == nil {
return errPassphraseEnvNotSet return errPassphraseEnvNotSet
} }
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
// Create secure buffer for passphrase
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
defer passphraseBuffer.Destroy()
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
@@ -565,27 +525,27 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
return nil return nil
} }
// vaultHasSecrets reports whether the vault directory contains any secrets // countVaultSecrets returns the number of secrets in the vault directory
func (cli *Instance) vaultHasSecrets(vaultDir string) (bool, error) { func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
secretsDir := filepath.Join(vaultDir, "secrets.d") secretsDir := filepath.Join(vaultDir, "secrets.d")
exists, err := afero.DirExists(cli.fs, secretsDir) exists, err := afero.DirExists(cli.fs, secretsDir)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to check secrets directory %s: %w", return 0, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err) secretsDir, err)
} }
if !exists { if !exists {
return false, nil return 0, nil
} }
entries, err := afero.ReadDir(cli.fs, secretsDir) entries, err := afero.ReadDir(cli.fs, secretsDir)
if err != nil { if err != nil {
return false, fmt.Errorf("failed to read secrets directory %s: %w", return 0, fmt.Errorf("failed to read secrets directory %s: %w",
secretsDir, err) secretsDir, err)
} }
return len(entries) > 0, nil return len(entries), nil
} }
// switchAwayFromVault selects another vault as current before removal // switchAwayFromVault selects another vault as current before removal
@@ -614,83 +574,107 @@ func (cli *Instance) switchAwayFromVault(
return nil return nil
} }
// RemoveVault removes a vault, holding the state directory lock while // RemoveVault removes a vault and all its secrets, after asking the user
// removeVault runs // to confirm unless force is set.
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error { func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
release, err := vault.LockStateDir(cli.fs, cli.stateDir) err := vault.ValidateVaultName(name)
if err != nil {
return err
}
var found vaultToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVaultToRemove(name)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
return cli.removeVault(cmd, name, force)
}
// removeVault removes a vault with safety checks
func (cli *Instance) removeVault(cmd *cobra.Command, name string, force bool) error {
// Get list of all vaults
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to list vaults: %w", err)
}
// Check if vault exists
if !slices.Contains(vaults, name) {
return fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
// Don't allow removing the last vault
if len(vaults) == 1 {
return errCannotRemoveLastVault
}
// Check if this is the current vault
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return fmt.Errorf("failed to get current vault: %w", err)
}
isCurrentVault := currentVault.GetName() == name
// Load the vault to check for secrets
vlt := vault.NewVault(cli.fs, cli.stateDir, name)
vaultDir, err := vlt.GetDirectory()
if err != nil {
return fmt.Errorf("failed to get vault directory: %w", err)
}
// Check if vault has secrets
hasSecrets, err := cli.vaultHasSecrets(vaultDir)
if err != nil {
return err
}
// Require --force if vault has secrets
if hasSecrets && !force {
return fmt.Errorf("vault '%s' %w", name, errVaultContainsSecrets)
}
// If removing current vault, switch to another vault first // If removing current vault, switch to another vault first
if isCurrentVault { if found.isCurrent {
err = cli.switchAwayFromVault(cmd, vaults, name) err = cli.switchAwayFromVault(cmd, found.vaults, name)
if err != nil { if err != nil {
return err return err
} }
} }
// Remove the vault directory // Remove the vault directory
err = secret.RemoveDirAtomic(cli.fs, vaultDir) err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove vault directory: %w", err) return fmt.Errorf("failed to remove vault directory: %w", err)
} }
cmd.Printf("Removed vault '%s'\n", name) cmd.Printf("Removed vault '%s'\n", name)
if hasSecrets { if found.secrets > 0 {
cmd.Printf("Warning: Vault contained secrets that have been " + cmd.Printf("Warning: Vault contained secrets that have been " +
"permanently deleted\n") "permanently deleted\n")
} }
return nil return nil
} }
// vaultToRemove is what removing a vault removes, as findVaultToRemove
// found it.
type vaultToRemove struct {
// dir is the vault's directory, which holds all its secrets.
dir string
secrets int
// vaults lists every vault, this one included, and isCurrent is set
// when this one is the current vault.
vaults []string
isCurrent bool
// question names what is removed, for the user to confirm.
question string
}
// findVaultToRemove checks that the vault exists and is not the last one,
// and counts its secrets.
func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
if err != nil {
return vaultToRemove{}, fmt.Errorf("failed to list vaults: %w", err)
}
if !slices.Contains(vaults, name) {
return vaultToRemove{},
fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
}
if len(vaults) == 1 {
return vaultToRemove{}, errCannotRemoveLastVault
}
currentVault, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return vaultToRemove{},
fmt.Errorf("failed to get current vault: %w", err)
}
vaultDir, err := vault.NewVault(cli.fs, cli.stateDir, name).GetDirectory()
if err != nil {
return vaultToRemove{},
fmt.Errorf("failed to get vault directory: %w", err)
}
secrets, err := cli.countVaultSecrets(vaultDir)
if err != nil {
return vaultToRemove{}, err
}
return vaultToRemove{
dir: vaultDir,
secrets: secrets,
vaults: vaults,
isCurrent: currentVault.GetName() == name,
question: fmt.Sprintf(
"Permanently remove vault '%s' and its %d secret(s)?",
name, secrets),
}, nil
}
+67 -25
View File
@@ -54,6 +54,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir), ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
destroySecrets := cli.readSecretEnv()
defer destroySecrets()
return cli.ListVersions(cmd, args[0]) return cli.ListVersions(cmd, args[0])
}, },
} }
@@ -86,7 +89,8 @@ func VersionCommands(cli *Instance) *cobra.Command {
Aliases: []string{"rm"}, Aliases: []string{"rm"},
Short: "Remove a specific version of a secret", Short: "Remove a specific version of a secret",
Long: "Remove a specific version of a secret. Cannot remove the " + Long: "Remove a specific version of a secret. Cannot remove the " +
"current version.", "current version. Asks for confirmation first; when stdin " +
"is not a terminal, fails unless --force is given.",
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
ValidArgsFunction: func( ValidArgsFunction: func(
cmd *cobra.Command, args []string, toComplete string, cmd *cobra.Command, args []string, toComplete string,
@@ -99,10 +103,15 @@ func VersionCommands(cli *Instance) *cobra.Command {
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
}, },
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return cli.RemoveVersion(cmd, args[0], args[1]) force, _ := cmd.Flags().GetBool("force")
return cli.RemoveVersion(cmd, args[0], args[1], force)
}, },
} }
removeCmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation")
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd) versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
return versionCmd return versionCmd
@@ -172,6 +181,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
currentVersion = "" currentVersion = ""
} }
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
// Get long-term key for decrypting metadata // Get long-term key for decrypting metadata
ltIdentity, err := vlt.GetOrDeriveLongTermKey() ltIdentity, err := vlt.GetOrDeriveLongTermKey()
if err != nil { if err != nil {
@@ -292,30 +303,62 @@ func (cli *Instance) PromoteVersion(
return nil return nil
} }
// RemoveVersion removes a specific version of a secret // RemoveVersion removes a specific version of a secret, after asking the
// user to confirm unless force is set.
func (cli *Instance) RemoveVersion( func (cli *Instance) RemoveVersion(
cmd *cobra.Command, secretName string, version string, cmd *cobra.Command, secretName string, version string, force bool,
) error { ) error {
err := vault.ValidateSecretName(secretName) err := vault.ValidateSecretName(secretName)
if err != nil { if err != nil {
return err return err
} }
release, err := vault.LockStateDir(cli.fs, cli.stateDir) var found versionToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findVersionToRemove(secretName, version)
return found.question, err
})
if err != nil { if err != nil {
return err return err
} }
defer release() defer release()
// Get current vault err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil {
return fmt.Errorf("failed to remove version: %w", err)
}
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
return nil
}
// versionToRemove is what removing a version removes, as
// findVersionToRemove found it.
type versionToRemove struct {
// dir is the version's directory.
dir string
// question names what is removed, for the user to confirm.
question string
}
// findVersionToRemove checks that the version exists in the secret in the
// current vault and is not its current version.
func (cli *Instance) findVersionToRemove(
secretName, version string,
) (versionToRemove, error) {
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir) vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return err return versionToRemove{}, err
} }
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
return err return versionToRemove{}, err
} }
// Get the encoded secret name // Get the encoded secret name
@@ -325,45 +368,44 @@ func (cli *Instance) RemoveVersion(
// Check if secret exists // Check if secret exists
exists, err := afero.DirExists(cli.fs, secretDir) exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err) return versionToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound) return versionToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
} }
// Check if version exists // Check if version exists
exists, err = secret.VersionExists(cli.fs, secretDir, version) exists, err = secret.VersionExists(cli.fs, secretDir, version)
if err != nil { if err != nil {
return fmt.Errorf("failed to check if version exists: %w", err) return versionToRemove{},
fmt.Errorf("failed to check if version exists: %w", err)
} }
if !exists { if !exists {
return fmt.Errorf("version '%s' %w '%s'", return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
version, errVersionNotFound, secretName) version, errVersionNotFound, secretName)
} }
// Get current version // Get current version
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir) currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
if err != nil { if err != nil {
return fmt.Errorf("failed to get current version: %w", err) return versionToRemove{},
fmt.Errorf("failed to get current version: %w", err)
} }
// Don't allow removing the current version // Don't allow removing the current version
if version == currentVersion { if version == currentVersion {
return fmt.Errorf("cannot remove the current version '%s'; %w", return versionToRemove{}, fmt.Errorf(
"cannot remove the current version '%s'; %w",
version, errCannotRemoveCurrentVersion) version, errCannotRemoveCurrentVersion)
} }
// Remove the version directory return versionToRemove{
versionDir := filepath.Join(secretDir, "versions", version) dir: filepath.Join(secretDir, "versions", version),
question: fmt.Sprintf("Permanently remove version %s of secret "+
err = secret.RemoveDirAtomic(cli.fs, versionDir) "'%s' from vault '%s'?", version, secretName, vlt.GetName()),
if err != nil { }, nil
return fmt.Errorf("failed to remove version: %w", err)
}
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
return nil
} }
+36 -11
View File
@@ -45,6 +45,17 @@ const (
testStateDir = "/test/state" testStateDir = "/test/state"
) )
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// Helper function to add a version of the "test/secret" secret to the // Helper function to add a version of the "test/secret" secret to the
// vault with proper buffer protection // vault with proper buffer protection
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) { func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
@@ -61,11 +72,9 @@ func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
func setupTestVault(t *testing.T, fs afero.Fs) { func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper() t.Helper()
// Set mnemonic for testing
t.Setenv(secret.EnvMnemonic, testMnemonic)
// Create vault // Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default") vlt, err := vault.CreateVault(fs, testStateDir, "default",
testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -83,11 +92,13 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
require.NoError(t, err) require.NoError(t, err)
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsCommand(t *testing.T) { func TestListVersionsCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key // Set up vault with long-term key
setupTestVault(t, fs) setupTestVault(t, fs)
@@ -96,6 +107,8 @@ func TestListVersionsCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false) addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -139,8 +152,9 @@ func TestListVersionsCommand(t *testing.T) {
assert.Equal(t, 2, versionLines) assert.Equal(t, 2, versionLines)
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsNonExistentSecret(t *testing.T) { func TestListVersionsNonExistentSecret(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -161,8 +175,9 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteVersionCommand(t *testing.T) { func TestPromoteVersionCommand(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -174,6 +189,8 @@ func TestPromoteVersionCommand(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = testMnemonicBuffer(t)
addTestSecret(t, vlt, []byte("version-1"), false) addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -224,8 +241,9 @@ func TestPromoteVersionCommand(t *testing.T) {
assert.Equal(t, []byte("version-1"), promoted.Bytes()) assert.Equal(t, []byte("version-1"), promoted.Bytes())
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestPromoteNonExistentVersion(t *testing.T) { func TestPromoteNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
@@ -252,11 +270,13 @@ func TestPromoteNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWithVersion(t *testing.T) { func TestGetSecretWithVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
cli.Mnemonic = testMnemonicBuffer(t)
// Set up vault with long-term key // Set up vault with long-term key
setupTestVault(t, fs) setupTestVault(t, fs)
@@ -265,6 +285,8 @@ func TestGetSecretWithVersion(t *testing.T) {
vlt, err := vault.GetCurrentVault(fs, stateDir) vlt, err := vault.GetCurrentVault(fs, stateDir)
require.NoError(t, err) require.NoError(t, err)
vlt.Mnemonic = cli.Mnemonic
addTestSecret(t, vlt, []byte("version-1"), false) addTestSecret(t, vlt, []byte("version-1"), false)
time.Sleep(10 * time.Millisecond) time.Sleep(10 * time.Millisecond)
@@ -298,10 +320,12 @@ func TestGetSecretWithVersion(t *testing.T) {
assert.Equal(t, "version-1", buf.String()) assert.Equal(t, "version-1", buf.String())
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestGetSecretWritesBinaryValue(t *testing.T) { func TestGetSecretWritesBinaryValue(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
cli := NewCLIInstanceWithStateDir(fs, testStateDir) cli := NewCLIInstanceWithStateDir(fs, testStateDir)
cli.Mnemonic = testMnemonicBuffer(t)
setupTestVault(t, fs) setupTestVault(t, fs)
@@ -361,8 +385,9 @@ func TestVersionCommandStructure(t *testing.T) {
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short) assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
} }
//nolint:paralleltest // uses t.Setenv via setupTestVault
func TestListVersionsEmptyOutput(t *testing.T) { func TestListVersionsEmptyOutput(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
stateDir := testStateDir stateDir := testStateDir
cli := NewCLIInstanceWithStateDir(fs, stateDir) cli := NewCLIInstanceWithStateDir(fs, stateDir)
+30 -7
View File
@@ -15,6 +15,7 @@ package macse
import "C" import "C"
import ( import (
"errors"
"fmt" "fmt"
"unsafe" "unsafe"
) )
@@ -38,10 +39,10 @@ const (
) )
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth. // CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion). // Returns the uncompressed public key bytes (65 bytes) and the identity hash
// (for deletion). If getting the public key fails, CreateKey deletes the key
// again; a failure to delete is returned along with the first error.
func CreateKey(label string) (publicKey []byte, hash string, err error) { func CreateKey(label string) (publicKey []byte, hash string, err error) {
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize)
var hashBuf [hashBufferSize]C.char var hashBuf [hashBufferSize]C.char
var errBuf [errorBufferSize]C.char var errBuf [errorBufferSize]C.char
@@ -49,7 +50,6 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
result := C.se_create_key(cLabel, result := C.se_create_key(cLabel,
&pubKeyBuf[0], &pubKeyLen,
&hashBuf[0], C.int(hashBufferSize), &hashBuf[0], C.int(hashBufferSize),
&errBuf[0], C.int(errorBufferSize)) &errBuf[0], C.int(errorBufferSize))
@@ -57,9 +57,30 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
h := C.GoString(&hashBuf[0]) h := C.GoString(&hashBuf[0])
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
pubKeyLen := C.int(p256UncompressedKeySize)
result = C.se_copy_public_key(cLabel,
&pubKeyBuf[0], &pubKeyLen,
&errBuf[0], C.int(errorBufferSize))
if result != 0 {
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
deleteErr := DeleteKey(h)
if deleteErr != nil {
err = errors.Join(err,
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
}
return nil, "", err
}
//nolint:nlreturn // CGo result extraction
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
return pk, h, nil return pk, h, nil
} }
@@ -83,7 +104,8 @@ func Encrypt(label string, plaintext []byte) ([]byte, error) {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction //nolint:nlreturn // CGo result extraction
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
return out, nil return out, nil
} }
@@ -107,7 +129,8 @@ func Decrypt(label string, ciphertext []byte) ([]byte, error) {
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0])) return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
} }
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction //nolint:nlreturn // CGo result extraction
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
return out, nil return out, nil
} }
+6 -6
View File
@@ -1,28 +1,28 @@
//go:build !darwin //go:build !darwin || !cgo
// Package macse provides Go bindings for macOS Secure Enclave operations. // Package macse provides Go bindings for macOS Secure Enclave operations.
package macse package macse
import "errors" import "errors"
var errNotSupported = errors.New("secure enclave is only supported on macOS") var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
// CreateKey is not supported on non-darwin platforms. // CreateKey fails: the Secure Enclave needs a macOS build with cgo.
func CreateKey(_ string) ([]byte, string, error) { func CreateKey(_ string) ([]byte, string, error) {
return nil, "", errNotSupported return nil, "", errNotSupported
} }
// Encrypt is not supported on non-darwin platforms. // Encrypt fails: the Secure Enclave needs a macOS build with cgo.
func Encrypt(_ string, _ []byte) ([]byte, error) { func Encrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported return nil, errNotSupported
} }
// Decrypt is not supported on non-darwin platforms. // Decrypt fails: the Secure Enclave needs a macOS build with cgo.
func Decrypt(_ string, _ []byte) ([]byte, error) { func Decrypt(_ string, _ []byte) ([]byte, error) {
return nil, errNotSupported return nil, errNotSupported
} }
// DeleteKey is not supported on non-darwin platforms. // DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
func DeleteKey(_ string) error { func DeleteKey(_ string) error {
return errNotSupported return errNotSupported
} }
+5 -4
View File
@@ -1,5 +1,4 @@
//go:build darwin //go:build darwin && cgo
// +build darwin
package macse package macse
@@ -45,7 +44,8 @@ func TestCreateAndDeleteKey(t *testing.T) {
// Verify valid uncompressed P-256 public key // Verify valid uncompressed P-256 public key
if len(pubKey) != p256UncompressedKeySize { if len(pubKey) != p256UncompressedKeySize {
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey)) t.Fatalf("expected public key length %d, got %d",
p256UncompressedKeySize, len(pubKey))
} }
if pubKey[0] != 0x04 { if pubKey[0] != 0x04 {
@@ -83,7 +83,8 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
}() }()
// Test data simulating an age private key // Test data simulating an age private key
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ") plaintext := []byte("AGE-SECRET-KEY-1" +
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
// Encrypt // Encrypt
ciphertext, err := Encrypt(testKeyLabel, plaintext) ciphertext, err := Encrypt(testKeyLabel, plaintext)
+14 -4
View File
@@ -5,20 +5,30 @@
#include <stdint.h> #include <stdint.h>
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth. // se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
// finds its identity hash. If the hash cannot be found, the key exists but
// se_create_key fails, with an error naming the label.
// label: unique identifier for the CTK identity (UTF-8 C string) // label: unique identifier for the CTK identity (UTF-8 C string)
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
// pub_key_len: on input, size of pub_key_out; on output, actual size written
// hash_out: output buffer for the identity hash (for deletion) // hash_out: output buffer for the identity hash (for deletion)
// hash_out_len: size of hash_out buffer // hash_out_len: size of hash_out buffer
// error_out: output buffer for error message // error_out: output buffer for error message
// error_out_len: size of error_out buffer // error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure. // Returns 0 on success, -1 on failure.
int se_create_key(const char *label, int se_create_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_len, char *hash_out, int hash_out_len,
char *error_out, int error_out_len); char *error_out, int error_out_len);
// se_copy_public_key copies the public key of a CTK identity.
// label: label of the CTK identity
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
// pub_key_len: on input, size of pub_key_out; on output, actual size written
// error_out: output buffer for error message
// error_out_len: size of error_out buffer
// Returns 0 on success, -1 on failure.
int se_copy_public_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *error_out, int error_out_len);
// se_encrypt encrypts data using the SE-backed public key (ECIES). // se_encrypt encrypts data using the SE-backed public key (ECIES).
// label: label of the CTK identity whose public key to use // label: label of the CTK identity whose public key to use
// plaintext: data to encrypt // plaintext: data to encrypt
+50 -35
View File
@@ -47,7 +47,6 @@ static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int
} }
int se_create_key(const char *label, int se_create_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *hash_out, int hash_out_len, char *hash_out, int hash_out_len,
char *error_out, int error_out_len) { char *error_out, int error_out_len) {
@autoreleasepool { @autoreleasepool {
@@ -87,7 +86,56 @@ int se_create_key(const char *label,
return -1; return -1;
} }
// Retrieve the public key from the created identity // Get the identity hash, which deleting the key needs, by parsing
// sc_auth list output
hash_out[0] = '\0';
NSTask *listTask = [[NSTask alloc] init];
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
listTask.arguments = @[@"list-ctk-identities"];
NSPipe *listPipe = [NSPipe pipe];
listTask.standardOutput = listPipe;
listTask.standardError = [NSPipe pipe];
if ([listTask launchAndReturnError:&nsError]) {
[listTask waitUntilExit];
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
NSString *listStr = [[NSString alloc] initWithData:listData
encoding:NSUTF8StringEncoding];
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
if ([line containsString:labelStr]) {
NSMutableArray *tokens = [NSMutableArray array];
for (NSString *part in [line componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]) {
if (part.length > 0) {
[tokens addObject:part];
}
}
if (tokens.count > 1) {
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
}
break;
}
}
}
if (hash_out[0] == '\0') {
NSString *msg = [NSString stringWithFormat:
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
label];
snprintf_error(error_out, error_out_len, msg);
return -1;
}
return 0;
}
}
int se_copy_public_key(const char *label,
uint8_t *pub_key_out, int *pub_key_len,
char *error_out, int error_out_len) {
@autoreleasepool {
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len); SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
if (!privateKey) { if (!privateKey) {
return -1; return -1;
@@ -126,39 +174,6 @@ int se_create_key(const char *label,
*pub_key_len = (int)length; *pub_key_len = (int)length;
CFRelease(pubKeyData); CFRelease(pubKeyData);
// Get the identity hash by parsing sc_auth list output
hash_out[0] = '\0';
NSTask *listTask = [[NSTask alloc] init];
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
listTask.arguments = @[@"list-ctk-identities"];
NSPipe *listPipe = [NSPipe pipe];
listTask.standardOutput = listPipe;
listTask.standardError = [NSPipe pipe];
if ([listTask launchAndReturnError:&nsError]) {
[listTask waitUntilExit];
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
NSString *listStr = [[NSString alloc] initWithData:listData
encoding:NSUTF8StringEncoding];
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
if ([line containsString:labelStr]) {
NSMutableArray *tokens = [NSMutableArray array];
for (NSString *part in [line componentsSeparatedByCharactersInSet:
[NSCharacterSet whitespaceCharacterSet]]) {
if (part.length > 0) {
[tokens addObject:part];
}
}
if (tokens.count > 1) {
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
}
break;
}
}
}
return 0; return 0;
} }
} }
+88 -2
View File
@@ -1,12 +1,19 @@
package secret package secret
import ( import (
"errors"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"strings"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// tempNamePart is in the name of every temporary file WriteFileAtomic makes,
// ".NAME.tmp-123", and every temporary directory TempDirFor makes, ".tmp-123".
const tempNamePart = ".tmp-"
// WriteFileAtomic replaces the file at path with data so that a reader, or // WriteFileAtomic replaces the file at path with data so that a reader, or
// a crash at any moment, finds either the old content or the new, never a // a crash at any moment, finds either the old content or the new, never a
// partial file. The data goes into a temporary file that afero.TempFile // partial file. The data goes into a temporary file that afero.TempFile
@@ -15,7 +22,7 @@ import (
// temporary file is removed if any step fails. // temporary file is removed if any step fails.
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error { func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
tmp, err := afero.TempFile(fs, filepath.Dir(path), tmp, err := afero.TempFile(fs, filepath.Dir(path),
"."+filepath.Base(path)+".tmp-*") "."+filepath.Base(path)+tempNamePart+"*")
if err != nil { if err != nil {
return fmt.Errorf("failed to create temporary file for %s: %w", path, err) return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
} }
@@ -52,7 +59,7 @@ func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
// Its name leaves out target's, which may already be as long as a file name // Its name leaves out target's, which may already be as long as a file name
// can be. // can be.
func TempDirFor(fs afero.Fs, target string) (string, error) { func TempDirFor(fs afero.Fs, target string) (string, error) {
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-") dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), tempNamePart)
if err != nil { if err != nil {
return "", fmt.Errorf( return "", fmt.Errorf(
"failed to create temporary directory for %s: %w", target, err) "failed to create temporary directory for %s: %w", target, err)
@@ -61,6 +68,85 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
return dir, nil return dir, nil
} }
// RemoveLeftovers deletes from dir the temporary files of WriteFileAtomic
// and the temporary directories of TempDirFor that a command killed
// part-way left there: each entry whose name starts with "." and holds
// tempNamePart. The caller must hold the state directory lock, so that no
// running command is still using one. A dir that does not exist holds none.
func RemoveLeftovers(fs afero.Fs, dir string) error {
entries, err := afero.ReadDir(fs, dir)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("failed to read %s: %w", dir, err)
}
for _, entry := range entries {
name := entry.Name()
if !strings.HasPrefix(name, ".") || !strings.Contains(name, tempNamePart) {
continue
}
path := filepath.Join(dir, name)
err = fs.RemoveAll(path)
if err != nil {
return fmt.Errorf("failed to remove %s: %w", path, err)
}
Debug("Removed what an interrupted command left", "path", path)
}
return nil
}
// WriteDir calls write to write the files of the new directory dir into a
// temporary directory from TempDirFor, which is then renamed to dir, so that
// neither a failure nor a crash leaves dir half-written; on a failure the
// temporary directory is removed, and a failure to remove it is returned
// along with the first. A directory cannot be replaced in one rename, so if
// dir already exists, WriteDir fails without calling write.
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
exists, err := afero.Exists(fs, dir)
if err != nil {
return fmt.Errorf("failed to check for %s: %w", dir, err)
}
if exists {
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
}
// Create the directory the finished one is renamed into
err = fs.MkdirAll(filepath.Dir(dir), DirPerms)
if err != nil {
return fmt.Errorf("failed to create %s: %w", filepath.Dir(dir), err)
}
tmp, err := TempDirFor(fs, dir)
if err != nil {
return err
}
err = write(tmp)
if err == nil {
err = fs.Rename(tmp, dir)
}
if err != nil {
removeErr := fs.RemoveAll(tmp)
if removeErr != nil {
err = errors.Join(err,
fmt.Errorf("failed to remove %s: %w", tmp, removeErr))
}
return err
}
return nil
}
// RemoveDirAtomic deletes the directory dir so that it disappears in one // RemoveDirAtomic deletes the directory dir so that it disappears in one
// rename: dir is moved into a new directory from TempDirFor, which is then // rename: dir is moved into a new directory from TempDirFor, which is then
// deleted. A crash part-way leaves only that temporary directory behind. // deleted. A crash part-way leaves only that temporary directory behind.
+301 -38
View File
@@ -8,6 +8,7 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"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/awnumar/memguard"
@@ -35,6 +36,10 @@ const currentFile = "current"
// unlockerMetadataFile is the file a new unlocker writes last. // unlockerMetadataFile is the file a new unlocker writes last.
const unlockerMetadataFile = "unlocker-metadata.json" const unlockerMetadataFile = "unlocker-metadata.json"
// privKeyFile is the file that holds the encrypted private key of a version
// or of a passphrase unlocker.
const privKeyFile = "priv.age"
// unlockerPassphrase protects the passphrase unlockers the tests create. // unlockerPassphrase protects the passphrase unlockers the tests create.
// //
//nolint:gosec // G101: test data, not a real credential //nolint:gosec // G101: test data, not a real credential
@@ -187,6 +192,22 @@ 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(
@@ -215,7 +236,7 @@ func newVaultWithSecret(
) *vault.Vault { ) *vault.Vault {
t.Helper() t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name) vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value)) buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -325,14 +346,14 @@ func TestRemoveDirAtomic(t *testing.T) {
// named with 255 bytes, the most a file name may have, on the real // named with 255 bytes, the most a file name may have, on the real
// filesystem: the temporary directories they use must fit that limit too. // filesystem: the temporary directories they use must fit that limit too.
func TestLongestNames(t *testing.T) { func TestLongestNames(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
const longestName = 255 const longestName = 255
fs := afero.NewOsFs() fs := afero.NewOsFs()
name := strings.Repeat("a", longestName) name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name) vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long")) value := memguard.NewBufferFromBytes([]byte("long"))
@@ -357,13 +378,13 @@ func TestLongestNames(t *testing.T) {
// another vault, as a forced move between vaults does, and makes the last // another vault, as a forced move between vaults does, and makes the last
// step that completes the copy fail. The secret it was to replace must // step that completes the copy fail. The secret it was to replace must
// still be there unchanged: it may go only once its replacement is whole. // still be there unchanged: it may go only once its replacement is whole.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) { func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
src := newVaultWithSecret(t, base, stateDir, "source", "new") src := newVaultWithSecret(t, base, stateDir, "source", "new")
dest := newVaultWithSecret(t, base, stateDir, "dest", "old") dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
@@ -396,13 +417,13 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
// directory directly in secrets.d or in a versions directory. Those are // directory directly in secrets.d or in a versions directory. Those are
// listed to find secrets and versions, so a temporary directory made there // listed to find secrets and versions, so a temporary directory made there
// would be listed while half-built, and one left by a crash would stay. // would be listed while half-built, and one left by a crash would stay.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestTempDirsStayOutOfListings(t *testing.T) { func TestTempDirsStayOutOfListings(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
newVaultWithSecret(t, base, stateDir, "default", "first") newVaultWithSecret(t, base, stateDir, "default", "first")
@@ -415,6 +436,7 @@ func TestTempDirsStayOutOfListings(t *testing.T) {
return nil return nil
}} }}
vlt := vault.NewVault(fs, stateDir, "default") vlt := vault.NewVault(fs, stateDir, "default")
vlt.Mnemonic = testMnemonicBuffer(t)
value := memguard.NewBufferFromBytes([]byte("second")) value := memguard.NewBufferFromBytes([]byte("second"))
defer value.Destroy() defer value.Destroy()
@@ -453,7 +475,7 @@ func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
if exists { if exists {
assert.ElementsMatch(t, assert.ElementsMatch(t,
[]string{"pub.age", "value.age", "priv.age", "metadata.age"}, []string{"pub.age", "value.age", privKeyFile, "metadata.age"},
dirNames(t, base, versionDir), dirNames(t, base, versionDir),
"version directory visible before it was complete") "version directory visible before it was complete")
} }
@@ -496,7 +518,7 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
writeLongTermKey(t, base, stateDir) writeLongTermKey(t, base, stateDir)
fs := hookFs{Fs: base, before: func(op, path string) error { fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(path) == "priv.age" { if op == opRename && filepath.Base(path) == privKeyFile {
return errInjected return errInjected
} }
@@ -523,13 +545,13 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
// unlocker again and checks, before each change this makes, that the file // unlocker again and checks, before each change this makes, that the file
// naming the current one exists: a reader or a crash never finds it // naming the current one exists: a reader or a crash never finds it
// missing. // missing.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestCurrentFilesNeverMissing(t *testing.T) { func TestCurrentFilesNeverMissing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
for _, tfs := range testFilesystems { for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) { t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value") vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
@@ -621,11 +643,11 @@ func TestWriteFileAtomicTempFile(t *testing.T) {
// anything, so that it never leaves a partial unlocker, nor breaks the one // anything, so that it never leaves a partial unlocker, nor breaks the one
// it would replace. // it would replace.
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) { func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from t.Parallel()
t.Setenv(secret.EnvMnemonic, "")
// No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName) _, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
require.NoError(t, err) require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error { fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -642,37 +664,278 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
require.Error(t, err) require.Error(t, err)
} }
// TestPassphraseUnlockerWritesMetadataLast checks that the last file a new // TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
// passphrase unlocker writes in its directory is its metadata: an unlocker // creating a passphrase unlocker makes, that the unlocker's directory either
// directory without metadata is never used, so one interrupted earlier // does not exist or holds all of its files: a crash or a failure at any point
// cannot be. // leaves no partial unlocker.
func TestPassphraseUnlockerWritesMetadataLast(t *testing.T) { func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
// The vault has no unlocker yet, so any directory in here is
// the new one
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
fs := hookFs{Fs: base, before: func(string, string) error {
for _, name := range dirNames(t, base, unlockersDir) {
assert.ElementsMatch(t, files,
dirNames(t, base, filepath.Join(unlockersDir, name)),
"unlocker directory visible before it was complete")
}
return nil
}}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
hooked := vault.NewVault(fs, stateDir, testVaultName)
hooked.Mnemonic = vlt.Mnemonic
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
})
}
}
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
// passphrase unlocker twice, each time with only the current unlocker to open
// the vault. The first replacement fails right after making the new unlocker
// current, so the old one is not removed. The second checks, before every
// change it makes, that the vault opens with the passphrase through its
// current unlocker, which is what a crash at that change would leave; once it
// returns, the vault must have one passphrase unlocker left.
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
require.NoError(t, err)
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vlt.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Every change after the switch to the new unlocker fails
switched := false
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
if switched {
return errInjected
}
switched = op == opRename && path == currentUnlockerPath
return nil
}}
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.ErrorIs(t, err, errInjected)
unlockers, err := vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
checked := hookFs{Fs: base, before: func(string, string) error {
assertOpens()
return nil
}}
replacing = vault.NewVault(checked, stateDir, testVaultName)
replacing.Unlock(ltIdentity)
_, err = replacing.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assertOpens()
unlockers, err = vlt.ListUnlockers()
require.NoError(t, err)
assert.Len(t, unlockers, 1)
})
}
}
// vaultOpensCheck returns a function that checks that the test vault under
// stateDir opens through its current unlocker, with passphrase, to the
// long-term key ltIdentity. Opening it takes a second, so an unlocker
// directory it has opened through before is not opened again: it must hold
// the same files as then.
func vaultOpensCheck(
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
) func() {
t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
// The files of each unlocker directory the vault has opened through
opened := map[string]map[string]string{}
return func() {
t.Helper()
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
require.NoError(t, err)
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
if before, ok := opened[string(current)]; ok {
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
return
}
opener := vault.NewVault(fs, stateDir, testVaultName)
opener.UnlockPassphrase = passphrase
key, err := opener.UnlockVault()
require.NoError(t, err)
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
opened[string(current)] = files
}
}
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
// a file has been written in it, and checks that neither the directory nor
// its temporary directory is left behind; and, when the temporary directory
// cannot be removed either, that both failures are reported.
func TestWriteDirFailureLeavesNothing(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, dir := tfs.open(t)
listed := filepath.Join(dir, "unlockers.d")
target := filepath.Join(listed, "new")
writeThenFail := func(tmp string) error {
require.NoError(t, secret.WriteFileAtomic(base,
filepath.Join(tmp, unlockerMetadataFile), []byte("{}")))
return errInjected
}
err := secret.WriteDir(base, target, writeThenFail)
require.ErrorIs(t, err, errInjected)
// Nothing in the directory that is listed, nor beside it
assert.Empty(t, dirNames(t, base, listed))
assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir))
fs := hookFs{Fs: base, before: func(op, _ string) error {
if op == opRemove {
return os.ErrPermission
}
return nil
}}
err = secret.WriteDir(fs, target, writeThenFail)
require.ErrorIs(t, err, errInjected)
require.ErrorIs(t, err, os.ErrPermission)
assert.Empty(t, dirNames(t, base, listed))
})
}
}
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
// write, when the directory already exists, and leaves the directory as it
// was: it never writes into a directory in place.
func TestWriteDirRefusesExistingDir(t *testing.T) {
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
fs, dir := tfs.open(t)
target := filepath.Join(dir, "unlockers.d", "existing")
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
require.NoError(t, secret.WriteFileAtomic(fs,
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
err := secret.WriteDir(fs, target, func(string) error {
t.Error("write called for a directory that exists")
return nil
})
require.ErrorIs(t, err, os.ErrExist)
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
})
}
}
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
// Enclave unlocker into place fail after its Secure Enclave key is created:
// the key must be deleted again. Skipped when the add fails before that, as
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
// Enclave.
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
t.Parallel()
mnemonic := testMnemonicBuffer(t)
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName) _, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
vaultDir, err := vlt.GetDirectory() // The unlocker's directory is named se-<label of its Secure Enclave key>
require.NoError(t, err) var seKeyLabel string
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase") fs := hookFs{Fs: base, before: func(op, path string) error {
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
var last string return errInjected
fs := hookFs{Fs: base, before: func(_, path string) error {
if filepath.Dir(path) == unlockerDir {
last = filepath.Base(path)
} }
return nil return nil
}} }}
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase)) _, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
defer passphrase.Destroy() nil)
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName). if seKeyLabel == "" {
CreatePassphraseUnlocker(passphrase) t.Skipf("the add failed before moving the unlocker into place: %v", err)
require.NoError(t, err) }
assert.Equal(t, unlockerMetadataFile, last)
require.ErrorIs(t, err, errInjected)
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
assert.Error(t, err, "Secure Enclave key left behind")
} }
+6
View File
@@ -16,6 +16,12 @@ 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
+18
View File
@@ -7,6 +7,7 @@ import (
"io" "io"
"os" "os"
"syscall" "syscall"
"unsafe"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -102,6 +103,23 @@ func DecryptWithIdentity(
return resultBuffer, nil return resultBuffer, nil
} }
// IdentityToLockedBuffer returns the private key of id, in age's text form, in
// a new locked buffer. The caller must destroy it.
//
// This is best effort. age gives the key only as a string in ordinary memory.
// The bytes of that string are moved into the buffer, which overwrites them,
// although Go otherwise never changes a string; nothing else holds this one.
// The copies age makes while building the string are left in ordinary memory.
// Avoiding those would mean encoding the key here, straight into the buffer.
func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer {
key := id.String()
//nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes
keyBytes := unsafe.Slice(unsafe.StringData(key), len(key))
return memguard.NewBufferFromBytes(keyBytes)
}
// EncryptWithPassphrase encrypts data using a passphrase with age's // EncryptWithPassphrase encrypts data using a passphrase with age's
// scrypt-based encryption. Both data and passphrase parameters should // scrypt-based encryption. Both data and passphrase parameters should
// be LockedBuffers for secure memory handling // be LockedBuffers for secure memory handling
+29
View File
@@ -0,0 +1,29 @@
package secret_test
import (
"testing"
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
// private key, and that the identity still gives that key afterwards: the
// helper overwrites the string age returned, so age must not keep it.
func TestIdentityToLockedBuffer(t *testing.T) {
t.Parallel()
identity, err := age.GenerateX25519Identity()
require.NoError(t, err)
buffer := secret.IdentityToLockedBuffer(identity)
defer buffer.Destroy()
parsed, err := age.ParseX25519Identity(buffer.String())
require.NoError(t, err)
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
assert.Equal(t, identity.String(), buffer.String())
}
+43 -9
View File
@@ -1,5 +1,6 @@
//go:build darwin //go:build darwin
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
package secret package secret
import ( import (
@@ -8,6 +9,7 @@ import (
"testing" "testing"
"time" "time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -26,19 +28,44 @@ 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.
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") } func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") } panic("not used")
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) { }
//nolint:ireturn // implements VaultInterface
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
panic("not used")
}
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
panic("not used")
}
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
panic("not used")
}
func (v *realVault) CreatePassphraseUnlocker(
*memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
panic("not used") panic("not used")
} }
// createRealVault sets up a complete vault directory structure on an in-memory // createRealVault sets up a complete vault directory structure on an in-memory
// filesystem, identical to what vault.CreateVault produces. // filesystem, identical to what vault.CreateVault produces.
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault { func createRealVault(
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
) *realVault {
t.Helper() t.Helper()
vaultDir := filepath.Join(stateDir, "vaults.d", name) vaultDir := filepath.Join(stateDir, "vaults.d", name)
@@ -51,13 +78,18 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
} }
metaBytes, err := json.Marshal(metadata) metaBytes, err := json.Marshal(metadata)
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms)) require.NoError(t, afero.WriteFile(fs,
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
return &realVault{name: name, stateDir: stateDir, fs: fs} return &realVault{name: name, stateDir: stateDir, fs: fs}
} }
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) { func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" t.Parallel()
//nolint:dupword // BIP39 test mnemonic repeats words by design
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// Derive expected keys at two different indices to prove they differ. // Derive expected keys at two different indices to prove they differ.
key0, err := agehd.DeriveIdentity(testMnemonic, 0) key0, err := agehd.DeriveIdentity(testMnemonic, 0)
@@ -71,10 +103,12 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5) vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic) mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault) result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err) require.NoError(t, err)
defer result.Destroy() defer result.Destroy()
assert.Equal(t, key5.String(), string(result.Bytes()), assert.Equal(t, key5.String(), string(result.Bytes()),
+268 -256
View File
@@ -1,11 +1,11 @@
//go:build darwin //go:build darwin
// +build darwin
package secret package secret
import ( import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -17,23 +17,40 @@ import (
"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"
) )
const ( const (
agePrivKeyPassphraseLength = 64 agePrivKeyPassphraseLength = 64
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items // KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant //
//nolint:revive // ALL_CAPS is intentional for this constant
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
// 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"`
} }
@@ -45,6 +62,17 @@ 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",
@@ -52,50 +80,20 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
slog.String("unlocker_type", k.GetType()), slog.String("unlocker_type", k.GetType()),
) )
// Step 1: Get keychain item name keychainData, err := k.readKeychainData()
keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID()) return nil, err
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)
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath) encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
if err != nil { if err != nil {
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath) Debug("Failed to read encrypted age private key",
"error", err, "path", agePrivKeyPath)
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err) return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
} }
@@ -106,12 +104,17 @@ 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", "unlocker_id", k.GetID()) Debug("Decrypting age private key with keychain passphrase",
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase) "unlocker_id", k.GetID())
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err) agePrivKeyBuffer, err := DecryptWithPassphrase(
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase",
"error", err, "unlocker_id", k.GetID())
return nil, fmt.Errorf(
"failed to decrypt age private key with keychain passphrase: %w", err)
} }
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
@@ -140,7 +143,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 "keychain" return keychainUnlockerType
} }
// GetMetadata implements Unlocker interface // GetMetadata implements Unlocker interface
@@ -153,20 +156,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID implements Unlocker interface - generates ID from keychain item name // GetID implements Unlocker interface: the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string { func (k *KeychainUnlocker) GetID() string {
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain return filepath.Base(k.Directory)
// This matches the passphrase unlocker format
hostname, err := os.Hostname()
if err != nil {
hostname = "unknown"
}
// Use the creation timestamp from metadata
createdAt := k.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
} }
// Remove implements Unlocker interface - removes the keychain unlocker // Remove implements Unlocker interface - removes the keychain unlocker
@@ -174,58 +166,105 @@ func (k *KeychainUnlocker) Remove() error {
// Step 1: Get keychain item name // Step 1: Get keychain item name
keychainItemName, err := k.GetKeychainItemName() keychainItemName, err := k.GetKeychainItemName()
if err != nil { if err != nil {
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID()) Debug("Failed to get keychain item name during removal",
"error", err, "unlocker_id", k.GetID())
return fmt.Errorf("failed to get keychain item name: %w", err) return fmt.Errorf("failed to get keychain item name: %w", err)
} }
// 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 {
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName) err = deleteFromKeychain(keychainItemName)
if err != nil {
Debug("Failed to remove keychain item",
"error", err, "keychain_item", keychainItemName)
return fmt.Errorf("failed to remove keychain item: %w", err) return fmt.Errorf("failed to remove keychain item: %w", err)
} }
// 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 {
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory) err = RemoveDirAtomic(k.fs, k.Directory)
if err != nil {
Debug("Failed to remove keychain unlocker directory",
"error", err, "directory", k.Directory)
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err) return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
} }
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName) Debug("Successfully removed keychain unlocker",
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
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()
@@ -233,43 +272,21 @@ 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>-<date> // Format: secret-<vault>-<hostname>-<time>
enrollmentDate := time.Now().Format("2006-01-02") enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
} }
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker // getLongTermPrivateKey derives the long-term private key from mnemonic when
// it is not nil, else gets it through the current unlocker, which is given
// passphrase when it is a passphrase unlocker.
// Returns a LockedBuffer to ensure the private key is protected in memory // Returns a LockedBuffer to ensure the private key is protected in memory
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) { func getLongTermPrivateKey(
// Check if mnemonic is available in environment variable fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
envMnemonic := os.Getenv(EnvMnemonic) ) (*memguard.LockedBuffer, error) {
if envMnemonic != "" { if mnemonic != nil {
// Read vault metadata to get the correct derivation index return deriveLongTermPrivateKey(fs, vault, mnemonic)
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
@@ -278,6 +295,10 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
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 {
@@ -286,34 +307,43 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
// 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
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current passphrase unlocker: %w", err)
} }
case *PGPUnlocker: case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker // Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")) encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current PGP unlocker: %w", err)
} }
case *KeychainUnlocker: case *KeychainUnlocker:
// Read the encrypted long-term private key from another keychain unlocker // Read the encrypted long-term private key from another keychain
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age")) // unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs,
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err) return nil, fmt.Errorf("failed to read encrypted long-term key "+
"from current keychain unlocker: %w", err)
} }
default: default:
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation") return nil, errUnsupportedCurrentUnlocker
} }
// Decrypt long-term private key using current unlocker // Decrypt long-term private key using current unlocker
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity) ltPrivKeyBuffer, err := DecryptWithIdentity(
encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err) return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
} }
@@ -322,10 +352,51 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
return ltPrivKeyBuffer, nil return ltPrivKeyBuffer, nil
} }
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault // deriveLongTermPrivateKey derives the long-term private key from mnemonic at
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) { // the vault's derivation index, for getLongTermPrivateKey and
// getLongTermKeyForSE.
func deriveLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
return IdentityToLockedBuffer(ltIdentity), 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
if err := checkMacOSAvailable(); err != nil { err := checkMacOSAvailable()
if err != nil {
return nil, err return nil, err
} }
@@ -341,16 +412,13 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to generate keychain item name: %w", err) return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
} }
// Create unlocker directory using the keychain item name as the directory name // The unlocker directory is named after the keychain item
vaultDir, err := vault.GetDirectory() vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName) unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// Step 1: Generate a new age keypair for the keychain unlocker // Step 1: Generate a new age keypair for the keychain unlocker
ageIdentity, err := age.GenerateX25519Identity() ageIdentity, err := age.GenerateX25519Identity()
@@ -358,6 +426,8 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to generate age keypair: %w", err) return nil, fmt.Errorf("failed to generate age keypair: %w", err)
} }
ageRecipient := ageIdentity.Recipient().String()
// Step 2: Generate a random passphrase for encrypting the age private key // Step 2: Generate a random passphrase for encrypting the age private key
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength) agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
if err != nil { if err != nil {
@@ -365,49 +435,32 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
} }
defer agePrivKeyPassphrase.Destroy() defer agePrivKeyPassphrase.Destroy()
// Step 3: Store age recipient as plaintext // Step 3: Encrypt age private key with the generated passphrase
ageRecipient := ageIdentity.Recipient().String() agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := WriteFileAtomic(fs, recipientPath, []byte(ageRecipient)); err != nil {
return nil, fmt.Errorf("failed to write age recipient: %w", err)
}
// Step 4: Encrypt age private key with the generated passphrase and store on disk
// Create a secure buffer for the private key
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy() defer agePrivKeyBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, agePrivKeyPassphrase) encryptedAgePrivKey, err := EncryptWithPassphrase(
agePrivKeyBuffer, agePrivKeyPassphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
} }
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age") // Step 4: Get or derive the long-term private key
if err := WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey); err != nil { ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Step 5: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 6: Encrypt long-term private key to the new age unlocker // Step 5: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient()) encryptedLtPrivKeyToAge, err := EncryptToRecipient(
ltPrivKeyData, ageIdentity.Recipient())
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err) return nil, fmt.Errorf(
"failed to encrypt long-term private key to age unlocker: %w", err)
} }
// Write encrypted long-term private key // Step 6: Prepare keychain data
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Step 7: Prepare keychain data
keychainData := KeychainData{ keychainData := KeychainData{
AgePublicKey: ageRecipient, AgePublicKey: ageRecipient,
AgePrivKeyPassphrase: agePrivKeyPassphrase, AgePrivKeyPassphrase: agePrivKeyPassphrase,
@@ -420,17 +473,25 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
} }
defer keychainDataBuffer.Destroy() defer keychainDataBuffer.Destroy()
// Step 8: Store data in keychain return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil { encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
return nil, fmt.Errorf("failed to store data in keychain: %w", err) }
}
// Step 9: Create and write enhanced metadata // writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
// The data is stored after the unlocker's files are written, and the keychain
// item is deleted again if moving the unlocker into place then fails.
func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte,
keychainDataBuffer *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Step 7: Prepare enhanced metadata
keychainMetadata := KeychainUnlockerMetadata{ keychainMetadata := KeychainUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: "keychain", Type: keychainUnlockerType,
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{"keychain", "macos"}, Flags: []string{keychainUnlockerType, macOSFlag},
}, },
KeychainItemName: keychainItemName, KeychainItemName: keychainItemName,
} }
@@ -440,10 +501,51 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err) return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
} }
if err := WriteFileAtomic(fs, // Step 8: Write the unlocker's files, the metadata last, then store the
filepath.Join(unlockerDir, "unlocker-metadata.json"), // data in the keychain
metadataBytes); err != nil { stored := false
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
err = WriteDir(fs, unlockerDir, func(dir string) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
stored = true
return nil
})
if err != nil && stored {
deleteErr := deleteFromKeychain(keychainItemName)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
}
}
if err != nil {
return nil, err
} }
return &KeychainUnlocker{ return &KeychainUnlocker{
@@ -456,111 +558,21 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
// checkMacOSAvailable verifies that we're running on macOS // checkMacOSAvailable verifies that we're running on macOS
func checkMacOSAvailable() error { func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" { if runtime.GOOS != "darwin" {
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS) return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
} }
return nil return nil
} }
// validateKeychainItemName validates that a keychain item name is safe for command execution // validateKeychainItemName validates that a keychain item name is safe for
// command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
if itemName == "" { if itemName == "" {
return fmt.Errorf("keychain item name cannot be empty") return errKeychainItemNameEmpty
} }
if !keychainItemNameRegex.MatchString(itemName) { if !keychainItemNameRegex.MatchString(itemName) {
return fmt.Errorf("invalid keychain item name format: %s", itemName) return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
}
return nil
}
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
} }
return nil return nil
+104
View File
@@ -0,0 +1,104 @@
//go:build darwin && cgo
package secret
import (
"fmt"
"github.com/awnumar/memguard"
keychain "github.com/keybase/go-keychain"
)
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil {
return fmt.Errorf("data buffer is nil")
}
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
item.SetDescription("Secret vault keychain data")
item.SetData(data.Bytes())
item.SetSynchronizable(keychain.SynchronizableNo)
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
// First try to delete any existing item
deleteItem := keychain.NewItem()
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
deleteItem.SetAccount(itemName)
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
// Add the new item
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("failed to store item in keychain: %w", err)
}
return nil
}
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
func retrieveFromKeychain(itemName string) ([]byte, error) {
if err := validateKeychainItemName(itemName); err != nil {
return nil, fmt.Errorf("invalid keychain item name: %w", err)
}
query := keychain.NewItem()
query.SetSecClass(keychain.SecClassGenericPassword)
query.SetService(KEYCHAIN_APP_IDENTIFIER)
query.SetAccount(itemName)
query.SetMatchLimit(keychain.MatchLimitOne)
query.SetReturnData(true)
results, err := keychain.QueryItem(query)
if err != nil {
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("keychain item not found: %s", itemName)
}
return results[0].Data, nil
}
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
// If the item doesn't exist, this function returns nil (not an error) since the goal
// is to ensure the item is gone, and it already being gone satisfies that goal.
func deleteFromKeychain(itemName string) error {
if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err)
}
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(KEYCHAIN_APP_IDENTIFIER)
item.SetAccount(itemName)
if err := keychain.DeleteItem(item); err != nil {
// If the item doesn't exist, that's not an error - the goal is to ensure
// the item is gone, and it already being gone satisfies that goal.
// This is important for cleaning up unlocker directories when the keychain
// item has already been removed (e.g., manually by user, or synced vault
// from a different machine).
if err == keychain.ErrorItemNotFound {
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
return nil
}
return fmt.Errorf("failed to delete item from keychain: %w", err)
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
//go:build darwin && !cgo
package secret
import (
"errors"
"github.com/awnumar/memguard"
)
// In a macOS build without cgo, these take the place of the functions in
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
var errKeychainNotSupported = errors.New(
"keychain unlockers need a macOS build with cgo")
// storeInKeychain fails: the keychain needs a macOS build with cgo.
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
return errKeychainNotSupported
}
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
func retrieveFromKeychain(_ string) ([]byte, error) {
return nil, errKeychainNotSupported
}
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
func deleteFromKeychain(_ string) error {
return errKeychainNotSupported
}
+6 -10
View File
@@ -4,6 +4,7 @@ package secret
import ( import (
"errors" "errors"
"path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -60,9 +61,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID returns the unlocker ID // GetID returns the unlocker ID, the name of the unlocker's directory
func (k *KeychainUnlocker) GetID() string { func (k *KeychainUnlocker) GetID() string {
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain" return filepath.Base(k.Directory)
} }
// GetKeychainItemName returns an error on non-Darwin platforms // GetKeychainItemName returns an error on non-Darwin platforms
@@ -76,13 +77,8 @@ func (k *KeychainUnlocker) Remove() error {
} }
// CreateKeychainUnlocker returns an error on non-Darwin platforms // CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) { func CreateKeychainUnlocker(
return nil, errKeychainNotSupported _ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
} ) (*KeychainUnlocker, error) {
// getLongTermPrivateKey returns an error on non-Darwin platforms
func getLongTermPrivateKey(
_ afero.Fs, _ VaultInterface,
) (*memguard.LockedBuffer, error) {
return nil, errKeychainNotSupported return nil, errKeychainNotSupported
} }
+36 -6
View File
@@ -1,14 +1,16 @@
//go:build darwin //go:build darwin && cgo
// +build darwin
package secret package secret
import ( import (
"encoding/hex" "encoding/hex"
"os"
"path/filepath"
"runtime" "runtime"
"testing" "testing"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -35,7 +37,8 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Test 2: Retrieve data from keychain // Test 2: Retrieve data from keychain
retrievedData, err := retrieveFromKeychain(testItemName) retrievedData, err := retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve data from keychain") require.NoError(t, err, "Failed to retrieve data from keychain")
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data") assert.Equal(t, testData, string(retrievedData),
"Retrieved data doesn't match stored data")
// Test 3: Update existing item (store again with different data) // Test 3: Update existing item (store again with different data)
newTestData := "updated-test-data-67890" newTestData := "updated-test-data-67890"
@@ -48,7 +51,8 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
// Verify updated data // Verify updated data
retrievedData, err = retrieveFromKeychain(testItemName) retrievedData, err = retrieveFromKeychain(testItemName)
require.NoError(t, err, "Failed to retrieve updated data from keychain") require.NoError(t, err, "Failed to retrieve updated data from keychain")
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data") assert.Equal(t, newTestData, string(retrievedData),
"Retrieved data doesn't match updated data")
// Test 4: Delete from keychain // Test 4: Delete from keychain
err = deleteFromKeychain(testItemName) err = deleteFromKeychain(testItemName)
@@ -93,7 +97,8 @@ func TestKeychainInvalidItemName(t *testing.T) {
for _, name := range invalidNames { for _, name := range invalidNames {
err := storeInKeychain(name, testData) err := storeInKeychain(name, testData)
assert.Error(t, err, "Expected error for invalid name: %s", name) assert.Error(t, err, "Expected error for invalid name: %s", name)
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name) assert.Contains(t, err.Error(), "invalid keychain item name",
"Error should mention invalid name for: %s", name)
} }
// Test valid names (should not error on validation) // Test valid names (should not error on validation)
@@ -180,5 +185,30 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
// This is important for cleaning up unlocker directories when the keychain item // This is important for cleaning up unlocker directories when the keychain item
// has already been removed (e.g., manually by user, or on a different machine) // has already been removed (e.g., manually by user, or on a different machine)
err := deleteFromKeychain(testItemName) err := deleteFromKeychain(testItemName)
assert.NoError(t, err, "Deleting non-existent keychain item should not return an error") assert.NoError(t, err,
"Deleting non-existent keychain item should not return an error")
}
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
// unlocker into place fail after its data is stored in the keychain: the
// keychain item must be deleted again.
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
testItemName := "test-secret-keychain-unlocker-cleanup"
_ = deleteFromKeychain(testItemName)
// Moving the unlocker into a read-only directory fails
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
defer testBuffer.Destroy()
_, err := writeKeychainUnlocker(afero.NewOsFs(),
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
require.ErrorIs(t, err, os.ErrPermission,
"moving the unlocker into place should fail")
_, err = retrieveFromKeychain(testItemName)
assert.Error(t, err, "keychain item left behind")
} }
+34 -19
View File
@@ -19,6 +19,17 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon " + const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about" "abandon abandon abandon abandon abandon about"
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// writeTestPublicKey writes the unlocker public key and verifies it exists. // writeTestPublicKey writes the unlocker public key and verifies it exists.
func writeTestPublicKey( func writeTestPublicKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string, t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
@@ -163,7 +174,7 @@ func newTestPassphraseUnlocker(
return unlocker, ageIdentity, unlockerDir return unlocker, ageIdentity, unlockerDir
} }
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters //nolint:paralleltest // subtests share real-FS state, order matters
func TestPassphraseUnlockerWithRealFS(t *testing.T) { func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// This test uses real filesystem // This test uses real filesystem
if os.Getenv("CI") == "true" { if os.Getenv("CI") == "true" {
@@ -195,38 +206,42 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey) writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
}) })
// Set test environment variable (cleaned up automatically) passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase) defer passphrase.Destroy()
// Test getting identity from environment variable unlocker.Passphrase = passphrase
t.Run("GetIdentityFromEnv", func(t *testing.T) {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity from env: %v", err)
}
// Verify the identity matches what we expect // Test getting identity with the passphrase the unlocker was given,
expectedPubKey := ageIdentity.Recipient().String() // twice: using it must leave it intact for the next use
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
for range 2 {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity with passphrase: %v", err)
}
actualPubKey := identity.Recipient().String() // Verify the identity matches what we expect
if actualPubKey != expectedPubKey { expectedPubKey := ageIdentity.Recipient().String()
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey) actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
} }
}) })
// Unset the environment variable to test interactive prompt unlocker.Passphrase = nil
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
// Test getting identity from prompt (this would require mocking the // Test getting identity from prompt (this would require mocking the
// prompt). For real integration tests, we'd need a way to mock the // prompt). For real integration tests, we'd need a way to mock the
// passphrase input. Here we just verify the error is what we expect // passphrase input. Here we just verify the error is what we expect
// when no passphrase is available. // when no passphrase is available.
t.Run("GetIdentityWithoutEnv", func(t *testing.T) { t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
// This should fail since we're not in an interactive terminal // This should fail since we're not in an interactive terminal
_, err := unlocker.GetIdentity() _, err := unlocker.GetIdentity()
if err == nil { if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var") t.Errorf("Should have failed to get identity without a passphrase")
} }
}) })
+17 -24
View File
@@ -1,9 +1,9 @@
package secret package secret
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
@@ -11,6 +11,11 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// ErrPassphraseNotRead is wrapped in the error of a passphrase unlocker
// that could not read its passphrase from the terminal, for example because
// there is none. The unlocker itself was not tried.
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
// PassphraseUnlocker represents a passphrase-protected unlocker // PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct { type PassphraseUnlocker struct {
Directory string Directory string
@@ -110,12 +115,9 @@ func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements Unlocker interface - generates ID from creation timestamp // GetID implements Unlocker interface: the name of the unlocker's directory
func (p *PassphraseUnlocker) GetID() string { func (p *PassphraseUnlocker) GetID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase return filepath.Base(p.Directory)
createdAt := p.Metadata.CreatedAt
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
} }
// Remove implements Unlocker interface - removes the passphrase unlocker // Remove implements Unlocker interface - removes the passphrase unlocker
@@ -135,34 +137,25 @@ func (p *PassphraseUnlocker) Remove() error {
return nil return nil
} }
// getPassphrase retrieves the passphrase from memory, environment, or // getPassphrase returns a copy of p.Passphrase, or else asks the user for
// user input. Returns a LockedBuffer for secure memory handling // the passphrase. The caller must destroy the returned buffer.
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) { func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
// First check if we already have the passphrase
if p.Passphrase != nil && p.Passphrase.IsAlive() { if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID()) Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Return a copy of the passphrase buffer // Not NewBufferFromBytes, which would wipe p.Passphrase
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil passphrase := memguard.NewBuffer(p.Passphrase.Size())
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, nil
} }
Debug("No passphrase in memory, checking environment") Debug("No passphrase in memory, prompting user")
// Check environment variable for passphrase
passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return secureBuffer, nil
}
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase // Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ") secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil { if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID()) Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err) return nil, fmt.Errorf("%w: %w", ErrPassphraseNotRead, err)
} }
return secureBuffer, nil return secureBuffer, nil
+409 -327
View File
@@ -4,7 +4,9 @@ package secret_test
import ( import (
"bytes" "bytes"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@@ -22,23 +24,24 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// Register vault with secret package for testing // pgpUnlockerType is the type of a PGP unlocker.
func init() { const pgpUnlockerType = "pgp"
// Register the vault.GetCurrentVault function with the secret package
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) { var errNilDataBuffer = errors.New("data buffer is nil")
return vault.GetCurrentVault(fs, stateDir)
})
}
// setupNonInteractiveGPG creates a custom GPG environment for testing // setupNonInteractiveGPG creates a custom GPG environment for testing
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) { func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
t.Helper()
// Create GPG config file for non-interactive operation // Create GPG config file for non-interactive operation
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf") gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
gpgConfContent := `batch gpgConfContent := `batch
no-tty no-tty
pinentry-mode loopback pinentry-mode loopback
` `
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
if err != nil {
t.Fatalf("Failed to write GPG config file: %v", err) t.Fatalf("Failed to write GPG config file: %v", err)
} }
@@ -47,11 +50,15 @@ pinentry-mode loopback
origDecryptFunc := secret.GPGDecryptFunc origDecryptFunc := secret.GPGDecryptFunc
// Set custom GPG functions for this test // Set custom GPG functions for this test
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) { secret.GPGEncryptFunc = func(
data *memguard.LockedBuffer, keyID string,
) ([]byte, error) {
if data == nil { if data == nil {
return nil, fmt.Errorf("data buffer is nil") return nil, errNilDataBuffer
} }
cmd := exec.Command("gpg",
//nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
"--yes", "--yes",
@@ -63,11 +70,13 @@ pinentry-mode loopback
"-r", keyID) "-r", keyID)
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(data.Bytes()) cmd.Stdin = bytes.NewReader(data.Bytes())
if err := cmd.Run(); err != nil { err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String()) return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
} }
@@ -75,7 +84,8 @@ pinentry-mode loopback
} }
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) { secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
cmd := exec.Command("gpg", //nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(t.Context(), "gpg",
"--homedir", gnupgHomeDir, "--homedir", gnupgHomeDir,
"--batch", "--batch",
"--yes", "--yes",
@@ -85,11 +95,13 @@ pinentry-mode loopback
"--decrypt") "--decrypt")
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
cmd.Stdin = bytes.NewReader(encryptedData) cmd.Stdin = bytes.NewReader(encryptedData)
if err := cmd.Run(); err != nil { err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String()) return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
} }
@@ -105,20 +117,24 @@ pinentry-mode loopback
} }
// runGPGWithPassphrase executes a GPG command with the specified passphrase // runGPGWithPassphrase executes a GPG command with the specified passphrase
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) { func runGPGWithPassphrase(
cmdArgs := []string{ ctx context.Context,
gnupgHome, passphrase string, args []string, input io.Reader,
) ([]byte, error) {
cmdArgs := append([]string{
"--homedir=" + gnupgHome, "--homedir=" + gnupgHome,
"--batch", "--batch",
"--yes", "--yes",
"--pinentry-mode", "loopback", "--pinentry-mode", "loopback",
"--passphrase", passphrase, "--passphrase", passphrase,
} }, args...)
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("gpg", cmdArgs...) //nolint:gosec // G204: test runs gpg with test-controlled arguments
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
cmd.Stdin = input cmd.Stdin = input
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout cmd.Stdout = &stdout
cmd.Stderr = &stderr cmd.Stderr = &stderr
@@ -130,14 +146,96 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
return stdout.Bytes(), nil return stdout.Bytes(), nil
} }
// generateTestGPGKey generates a GPG key protected by passphrase in
// gnupgHomeDir and returns its key ID and fingerprint.
func generateTestGPGKey(
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
) (string, string) {
t.Helper()
// Create GPG batch file for key generation
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + passphrase + `
%commit
%echo Key generation completed
`
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
if err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
if err != nil {
t.Fatalf("Failed to list GPG keys: %v", err)
}
// Parse output to get key ID and fingerprint
var keyID, fingerprint string
for line := range strings.SplitSeq(string(output), "\n") {
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
if len(fields) >= 5 {
keyID = fields[4]
}
} else if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
fingerprint = fields[9]
break
}
}
}
if keyID == "" {
t.Fatalf("Failed to find GPG key ID in output: %s", output)
}
if fingerprint == "" {
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
}
t.Logf("Generated GPG key ID: %s", keyID)
t.Logf("Generated GPG fingerprint: %s", fingerprint)
return keyID, fingerprint
}
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestPGPUnlockerWithRealFS(t *testing.T) { func TestPGPUnlockerWithRealFS(t *testing.T) {
// Check if gpg is available // Check if gpg is available
if _, err := exec.LookPath("gpg"); err != nil { _, err := exec.LookPath("gpg")
if err != nil {
t.Log("GPG not available, PGP unlock key tests may not fully function") t.Log("GPG not available, PGP unlock key tests may not fully function")
// Continue anyway to test what we can // Continue anyway to test what we can
} }
// Create a temporary directory for our tests // Create a temporary directory for our tests. Not t.TempDir: its longer
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
// macOS sets on socket paths.
//
//nolint:usetesting // see the comment above
tempDir, err := os.MkdirTemp("", "secret-pgp-test-") tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
if err != nil { if err != nil {
t.Fatalf("Failed to create temp dir: %v", err) t.Fatalf("Failed to create temp dir: %v", err)
@@ -146,7 +244,9 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Create a temporary GNUPGHOME // Create a temporary GNUPGHOME
gnupgHomeDir := filepath.Join(tempDir, "gnupg") gnupgHomeDir := filepath.Join(tempDir, "gnupg")
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
err = os.MkdirAll(gnupgHomeDir, 0o700)
if err != nil {
t.Fatalf("Failed to create GNUPGHOME: %v", err) t.Fatalf("Failed to create GNUPGHOME: %v", err)
} }
@@ -159,64 +259,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// Setup non-interactive GPG with custom functions // Setup non-interactive GPG with custom functions
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir) setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
// Create GPG batch file for key generation keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
batchFile := filepath.Join(tempDir, "gen-key-batch")
batchContent := `%echo Generating a test key
Key-Type: RSA
Key-Length: 2048
Name-Real: Test User
Name-Email: test@example.com
Expire-Date: 0
Passphrase: ` + testPassphrase + `
%commit
%echo Key generation completed
`
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
t.Fatalf("Failed to write batch file: %v", err)
}
// Generate GPG key with batch mode
t.Log("Generating GPG key...")
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
[]string{"--gen-key", batchFile}, nil)
if err != nil {
t.Fatalf("Failed to generate GPG key: %v", err)
}
t.Log("GPG key generated successfully")
// Get the key ID and fingerprint
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
if err != nil {
t.Fatalf("Failed to list GPG keys: %v", err)
}
// Parse output to get key ID and fingerprint
var keyID, fingerprint string
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "sec:") {
fields := strings.Split(line, ":")
if len(fields) >= 5 {
keyID = fields[4]
}
} else if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
fingerprint = fields[9]
break
}
}
}
if keyID == "" {
t.Fatalf("Failed to find GPG key ID in output: %s", output)
}
if fingerprint == "" {
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
}
t.Logf("Generated GPG key ID: %s", keyID)
t.Logf("Generated GPG fingerprint: %s", fingerprint)
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere // Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
t.Setenv("GPG_AGENT_INFO", "") t.Setenv("GPG_AGENT_INFO", "")
@@ -224,11 +267,7 @@ Passphrase: ` + testPassphrase + `
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Test data
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// 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
@@ -237,162 +276,20 @@ Passphrase: ` + testPassphrase + `
// Test creation of a PGP unlock key through a vault // Test creation of a PGP unlock key through a vault
t.Run("CreatePGPUnlocker", func(t *testing.T) { t.Run("CreatePGPUnlocker", func(t *testing.T) {
// Set a limited test timeout to avoid hanging testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
})
defer timer.Stop()
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != "pgp" {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check if the key ID includes the GPG fingerprint
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
}
// Check if the key directory exists
unlockerDir := pgpUnlocker.GetDirectory()
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
// Read and verify metadata
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != "pgp" {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}) })
// Set up key directory for individual tests // Set up key directory for individual tests
unlockerDir := filepath.Join(tempDir, "unlocker") unlockerDir := filepath.Join(tempDir, "unlocker")
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
err = os.MkdirAll(unlockerDir, secret.DirPerms)
if err != nil {
t.Fatalf("Failed to create unlocker directory: %v", err) t.Fatalf("Failed to create unlocker directory: %v", err)
} }
// Set up test metadata // Set up test metadata
metadata := secret.UnlockerMetadata{ metadata := secret.UnlockerMetadata{
Type: "pgp", Type: pgpUnlockerType,
CreatedAt: time.Now(), CreatedAt: time.Now(),
Flags: []string{"gpg", "encrypted"}, Flags: []string{"gpg", "encrypted"},
} }
@@ -400,107 +297,292 @@ Passphrase: ` + testPassphrase + `
// Create a PGP unlocker for the remaining tests // Create a PGP unlocker for the remaining tests
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata) unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
// Test getting GPG key ID
t.Run("GetGPGKeyID", func(t *testing.T) {
// Create PGP metadata with GPG key ID
type PGPUnlockerMetadata struct {
secret.UnlockerMetadata
GPGKeyID string `json:"gpgKeyId"`
}
pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: metadata,
GPGKeyID: fingerprint,
}
// Write metadata file
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
t.Fatalf("Failed to write metadata: %v", err)
}
// Get GPG key ID
retrievedKeyID, err := unlocker.GetGPGKeyID()
if err != nil {
t.Fatalf("Failed to get GPG key ID: %v", err)
}
// Verify key ID (should be the fingerprint)
if retrievedKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
}
})
// Test getting identity from PGP unlocker // Test getting identity from PGP unlocker
t.Run("GetIdentity", func(t *testing.T) { t.Run("GetIdentity", func(t *testing.T) {
// Generate an age identity for testing testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}) })
// Test removing the unlocker // Test removing the unlocker
t.Run("RemoveUnlocker", func(t *testing.T) { t.Run("RemoveUnlocker", func(t *testing.T) {
// Ensure unlocker directory exists before removal testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}) })
} }
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
// and metadata.
func testCreatePGPUnlocker(
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
) {
t.Helper()
// Set a limited test timeout to avoid hanging
timer := time.AfterFunc(30*time.Second, func() {
t.Fatalf("Test timed out after 30 seconds")
})
defer timer.Stop()
mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
// Set the current vault
err = vault.SelectVault(fs, stateDir, vaultName)
if err != nil {
t.Fatalf("Failed to select vault: %v", err)
}
// Derive long-term key from mnemonic
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
if err != nil {
t.Fatalf("Failed to derive long-term key: %v", err)
}
// Get the vault directory
vaultDir, err := vlt.GetDirectory()
if err != nil {
t.Fatalf("Failed to get vault directory: %v", err)
}
// Write long-term public key
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
err = afero.WriteFile(fs, ltPubKeyPath,
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write long-term public key: %v", err)
}
// Unlock the vault
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}
// Verify passphrase unlocker was created
if passUnlocker == nil {
t.Fatal("Passphrase unlocker is nil")
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(
fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
// Verify the PGP unlock key was created
if pgpUnlocker == nil {
t.Fatal("PGP unlock key is nil")
}
// Check if the key has the correct type
if pgpUnlocker.GetType() != pgpUnlockerType {
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
}
// Check that the ID is the name of the unlocker's directory
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
}
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
}
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
// its files.
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
t.Helper()
// Check if the key directory exists
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
}
if !keyExists {
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
}
// Check if required files exist
recipientPath := filepath.Join(unlockerDir, "pub.txt")
recipientExists, err := afero.Exists(fs, recipientPath)
if err != nil {
t.Fatalf("Failed to check if recipient file exists: %v", err)
}
if !recipientExists {
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
}
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
privKeyExists, err := afero.Exists(fs, privKeyPath)
if err != nil {
t.Fatalf("Failed to check if private key file exists: %v", err)
}
if !privKeyExists {
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
}
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataExists, err := afero.Exists(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to check if metadata file exists: %v", err)
}
if !metadataExists {
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
}
longtermPath := filepath.Join(unlockerDir, "longterm.age")
longtermExists, err := afero.Exists(fs, longtermPath)
if err != nil {
t.Fatalf("Failed to check if longterm key file exists: %v", err)
}
if !longtermExists {
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
}
}
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
// unlockerDir names its type and the GPG key by fingerprint.
func checkPGPUnlockerMetadata(
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
) {
t.Helper()
// Read and verify metadata
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
metadataBytes, err := afero.ReadFile(fs, metadataPath)
if err != nil {
t.Fatalf("Failed to read metadata: %v", err)
}
var metadata struct {
ID string `json:"id"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
Flags []string `json:"flags"`
GPGKeyID string `json:"gpgKeyId"`
}
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
t.Fatalf("Failed to parse metadata: %v", err)
}
if metadata.Type != pgpUnlockerType {
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
}
if metadata.GPGKeyID != fingerprint {
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir, keyID string,
) {
t.Helper()
// Generate an age identity for testing
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
t.Fatalf("Failed to generate age identity: %v", err)
}
// Write the recipient
recipientPath := filepath.Join(unlockerDir, "pub.txt")
err = afero.WriteFile(fs, recipientPath,
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write recipient: %v", err)
}
// GPG encrypt the private key using our custom encrypt function
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer privKeyBuffer.Destroy()
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
if err != nil {
t.Fatalf("Failed to encrypt with GPG: %v", err)
}
// Write the encrypted data to a file
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
if err != nil {
t.Fatalf("Failed to write encrypted private key: %v", err)
}
// Now try to get the identity - this will use our custom GPGDecryptFunc
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity: %v", err)
}
// Verify the identity matches
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
}
}
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
func testRemovePGPUnlocker(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
) {
t.Helper()
// Ensure unlocker directory exists before removal
keyExists, err := afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if !keyExists {
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
}
// Remove unlocker
err = unlocker.Remove()
if err != nil {
t.Fatalf("Failed to remove unlocker: %v", err)
}
// Verify directory is gone
keyExists, err = afero.DirExists(fs, unlockerDir)
if err != nil {
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
}
if keyExists {
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
}
}
+110 -137
View File
@@ -155,21 +155,9 @@ func (p *PGPUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements Unlocker interface - generates ID from GPG key ID. // GetID implements Unlocker interface: the name of the unlocker's directory
// If the metadata has no usable GPG key ID, it warns with the unlocker's
// directory and returns "pgp-unknown", so listing the other unlockers
// still works.
func (p *PGPUnlocker) GetID() string { func (p *PGPUnlocker) GetID() string {
// Generate ID using GPG key ID: pgp-<keyid> return filepath.Base(p.Directory)
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
"directory", p.Directory, "error", err)
return "pgp-unknown"
}
return "pgp-" + gpgKeyID
} }
// Remove implements Unlocker interface - removes the PGP unlocker // Remove implements Unlocker interface - removes the PGP unlocker
@@ -184,172 +172,85 @@ func (p *PGPUnlocker) Remove() error {
return nil return nil
} }
// GetGPGKeyID returns the GPG key ID from metadata
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
// Load the metadata
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(p.fs, metadataPath)
if err != nil {
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
}
var pgpMetadata PGPUnlockerMetadata
err = json.Unmarshal(metadataData, &pgpMetadata)
if err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
if pgpMetadata.GPGKeyID == "" {
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
}
return pgpMetadata.GPGKeyID, nil
}
// generatePGPUnlockerName generates a unique name for the PGP unlocker // generatePGPUnlockerName generates a unique name for the PGP unlocker
// based on hostname and date // based on hostname and time
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)
} }
// Format: hostname-pgp-YYYY-MM-DD enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
} }
// preparePGPUnlockerDir checks GPG availability and creates the // pgpUnlockerDir returns the current vault and the directory in it for a
// unlocker directory in the current vault, returning the vault and the // new PGP unlocker, named after the host and the time.
// directory path.
// //
//nolint:ireturn // the vault is only available behind VaultInterface //nolint:ireturn // the vault is only available behind VaultInterface
func preparePGPUnlockerDir( func pgpUnlockerDir(
fs afero.Fs, stateDir string, fs afero.Fs, stateDir string,
) (VaultInterface, string, error) { ) (VaultInterface, string, error) {
// Check if GPG is available
err := checkGPGAvailable()
if err != nil {
return nil, "", err
}
// Get current vault // Get current vault
vault, err := GetCurrentVault(fs, stateDir) vault, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
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 date // Generate the unlocker name based on hostname and time
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)
} }
// Create unlocker directory using the generated name
vaultDir, err := vault.GetDirectory() vaultDir, err := vault.GetDirectory()
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to get vault directory: %w", err) return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName) return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
err = fs.MkdirAll(unlockerDir, DirPerms)
if err != nil {
return nil, "", fmt.Errorf("failed to create unlocker directory: %w", err)
}
return vault, unlockerDir, nil
} }
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault // CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
// Everything that can fail short of writing a file is done before anything
// is written, and the files are written through WriteDir, so a failure
// leaves no partial unlocker. The long-term key comes from mnemonic when it
// is not nil, else from the current unlocker, which is given passphrase when
// it is a passphrase unlocker.
func CreatePGPUnlocker( func CreatePGPUnlocker(
fs afero.Fs, stateDir string, gpgKeyID string, fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*PGPUnlocker, error) { ) (*PGPUnlocker, error) {
vault, unlockerDir, err := preparePGPUnlockerDir(fs, stateDir) err := checkGPGAvailable()
if err != nil { if err != nil {
return nil, err return nil, err
} }
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
if err != nil {
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 {
return nil, fmt.Errorf("failed to generate age keypair: %w", err) return nil, fmt.Errorf("failed to generate age keypair: %w", err)
} }
// Step 2: Store age recipient as plaintext // Step 2: Encrypt the long-term private key to the new keypair, and the
ageRecipient := ageIdentity.Recipient().String() // keypair's private key to the GPG key
recipientPath := filepath.Join(unlockerDir, "pub.txt") encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
vault, ageIdentity, gpgKeyID)
err = WriteFileAtomic(fs, recipientPath, []byte(ageRecipient))
if err != nil {
return nil, fmt.Errorf("failed to write age recipient: %w", err)
}
// Step 3: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer ltPrivKeyData.Destroy()
// Step 7: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf(
"failed to encrypt long-term private key to age unlocker: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
err = WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Step 8: Encrypt age private key to the GPG key ID
// Use memguard to protect the private key in memory
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
}
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
err = WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Steps 9-10: Resolve the fingerprint and write enhanced metadata
pgpMetadata, err := writePGPUnlockerMetadata(fs, unlockerDir, gpgKeyID)
if err != nil {
return nil, err
}
return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// writePGPUnlockerMetadata resolves the GPG key fingerprint and writes
// the unlocker metadata file, returning the metadata written.
func writePGPUnlockerMetadata(
fs afero.Fs, unlockerDir string, gpgKeyID string,
) (*PGPUnlockerMetadata, error) {
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
pgpMetadata := PGPUnlockerMetadata{ pgpMetadata := PGPUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
@@ -365,13 +266,85 @@ func writePGPUnlockerMetadata(
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err) return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
} }
err = WriteFileAtomic(fs, // Step 3: Write the unlocker's files, the metadata last
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes) err = WriteDir(fs, unlockerDir, func(dir string) error {
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err) return nil, err
} }
return &pgpMetadata, nil return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
// to the new PGP unlocker's age keypair, and that keypair's private key
// encrypted to the GPG key gpgKeyID.
func encryptPGPUnlockerKeys(
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
) ([]byte, []byte, error) {
// From the mnemonic or the current unlocker, as for a passphrase unlocker
ltIdentity, err := vault.GetOrDeriveLongTermKey()
if err != nil {
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
}
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient(
ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, nil, fmt.Errorf(
"failed to encrypt long-term private key to age unlocker: %w", err)
}
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
if err != nil {
return nil, nil, fmt.Errorf(
"failed to encrypt age private key with GPG: %w", err)
}
return encryptedLtPrivKey, encryptedAgePrivKey, nil
}
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
// metadata last.
func writePGPUnlockerFiles(
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
[]byte(ageRecipient.String()))
if err != nil {
return fmt.Errorf("failed to write age recipient: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted age private key: %w", err)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return nil
} }
// validateGPGKeyID validates that a GPG key ID is safe for command execution // validateGPGKeyID validates that a GPG key ID is safe for command execution
+104
View File
@@ -0,0 +1,104 @@
package secret_test
import (
"os"
"path/filepath"
"testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
const (
testGPGKeyID = "0123456789ABCDEF"
testGPGFingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"
)
// fakeGPGScript is a gpg for which `gpg --version` succeeds and anything
// else fails.
const fakeGPGScript = `#!/bin/sh
[ "$*" = --version ]
`
// installFakeGPG makes fakeGPGScript the only gpg on PATH for the test.
func installFakeGPG(t *testing.T) {
t.Helper()
dir := t.TempDir()
//nolint:gosec // G306: the script must be executable
err := os.WriteFile(filepath.Join(dir, "gpg"), []byte(fakeGPGScript), 0o700)
require.NoError(t, err)
t.Setenv("PATH", dir)
}
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
// getting the vault's long-term key, which used to come after part of the
// unlocker was written, and asserts that nothing is written. Getting the key
// fails because there is no mnemonic and no current unlocker.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t)
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
t.Errorf("changed %s", path)
return nil
}}
_, err = secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
require.Error(t, err)
vaultDir, err := vlt.GetDirectory()
require.NoError(t, err)
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
}
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
// the other, so on the same host and day, and checks that the second gets a
// directory of its own and leaves the first one's files as they were.
// CreatePGPUnlocker does not check whether the GPG key already has an
// unlocker, so the test key serves for both.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
installFakeGPG(t)
original := secret.GPGEncryptFunc
t.Cleanup(func() { secret.GPGEncryptFunc = original })
// Stands in for gpg, which the test does not have: "encrypts" by copying
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
return []byte(data.String()), nil
}
fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
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()))
}
+6 -192
View File
@@ -1,27 +1,18 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
var ( var (
// errSecretNotFound carries only the message tail; callers compose
// "secret <name> not found" around it so the emitted text is
// unchanged.
errSecretNotFound = errors.New("not found")
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
errGetEncryptedDataDeprecated = errors.New( errGetEncryptedDataDeprecated = errors.New(
"GetEncryptedData is deprecated - use version-specific methods") "GetEncryptedData is deprecated - use version-specific methods")
errGetCurrentVaultNotRegistered = errors.New( errGetCurrentVaultNotRegistered = errors.New(
@@ -35,6 +26,12 @@ type VaultInterface interface {
GetName() string GetName() string
GetFilesystem() afero.Fs GetFilesystem() afero.Fs
GetCurrentUnlocker() (Unlocker, error) GetCurrentUnlocker() (Unlocker, error)
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
// mnemonic to derive the long-term key from, and the passphrase for a
// current passphrase unlocker; nil for none.
SetMnemonic(mnemonic *memguard.LockedBuffer)
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
CreatePassphraseUnlocker( CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error) passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
} }
@@ -76,71 +73,6 @@ func NewSecret(vault VaultInterface, name string) *Secret {
} }
} }
// GetValue retrieves and decrypts the current version's value using the
// provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
// Check if secret exists
exists, err := s.Exists()
if err != nil {
Debug("Failed to check if secret exists during GetValue",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
Debug("Secret not found during GetValue",
"secret_name", s.Name, "vault_name", s.vault.GetName())
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
}
Debug("Secret exists, getting current version", "secret_name", s.Name)
// Get current version
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
if err != nil {
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to get current version: %w", err)
}
// Create version object
version := NewVersion(s.vault, s.Name, currentVersion)
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
return s.getValueViaMnemonic(version, envMnemonic)
}
Debug("Using unlocker for vault access", "secret_name", s.Name)
// Use the provided unlocker to get the vault's long-term private key
if unlocker == nil {
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
return nil, errUnlockerRequired
}
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
if err != nil {
return nil, err
}
DebugWith("Successfully obtained vault's long-term key",
slog.String("secret_name", s.Name),
slog.String("public_key", ltIdentity.Recipient().String()),
)
// Use the long-term key to decrypt the version
return version.GetValue(ltIdentity)
}
// LoadMetadata is deprecated - metadata is now per-version and encrypted // LoadMetadata is deprecated - metadata is now per-version and encrypted
func (s *Secret) LoadMetadata() error { func (s *Secret) LoadMetadata() error {
Debug("LoadMetadata called but is deprecated in versioned model", Debug("LoadMetadata called but is deprecated in versioned model",
@@ -208,124 +140,6 @@ func (s *Secret) Exists() (bool, error) {
return true, nil return true, nil
} }
// getValueViaMnemonic derives the vault's long-term key from the
// mnemonic in the environment and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic(
version *Version, envMnemonic string,
) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic from environment for direct long-term key derivation",
"secret_name", s.Name)
// Get vault directory to read metadata
vaultDir, err := s.vault.GetDirectory()
if err != nil {
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
// Load vault metadata to get the correct derivation index
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
if err != nil {
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
}
var metadata VaultMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
}
DebugWith("Using vault derivation index from metadata",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
)
// Use mnemonic with the vault's derivation index from metadata
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
if err != nil {
Debug("Failed to derive long-term key from mnemonic for secret",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf(
"failed to derive long-term key from mnemonic: %w", err)
}
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
// Use the long-term key to decrypt the version
return version.GetValue(ltIdentity)
}
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
// the vault's long-term private key.
func (s *Secret) getLongTermIdentityFromUnlocker(
unlocker Unlocker,
) (*age.X25519Identity, error) {
DebugWith("Getting vault's long-term key using unlocker",
slog.String("secret_name", s.Name),
slog.String("unlocker_type", unlocker.GetType()),
slog.String("unlocker_id", unlocker.GetID()),
)
// Step 1: Use the unlocker to get the vault's long-term private key
unlockIdentity, err := unlocker.GetIdentity()
if err != nil {
Debug("Failed to get unlocker identity",
"error", err, "secret_name", s.Name,
"unlocker_type", unlocker.GetType())
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
}
// Read the encrypted long-term private key from the unlocker directory
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
encryptedLtPrivKey, err := afero.ReadFile(
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
if err != nil {
Debug("Failed to read encrypted long-term private key",
"error", err, "path", encryptedLtPrivKeyPath)
return nil, fmt.Errorf(
"failed to read encrypted long-term private key: %w", err)
}
// Decrypt the encrypted long-term private key using the unlocker
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
if err != nil {
Debug("Failed to decrypt long-term private key",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
defer ltPrivKeyBuffer.Destroy()
// Parse the long-term private key
Debug("Parsing long-term private key", "secret_name", s.Name)
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse long-term private key",
"error", err, "secret_name", s.Name)
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
}
return ltIdentity, nil
}
// GetCurrentVault gets the current vault from the file system // GetCurrentVault gets the current vault from the file system
// This function is a wrapper around the actual implementation in the vault package // This function is a wrapper around the actual implementation in the vault package
// and exists to break the import cycle. // and exists to break the import cycle.
+18 -32
View File
@@ -12,7 +12,6 @@ import (
"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"
"github.com/stretchr/testify/require"
) )
// testMnemonicValue is the standard BIP39 test vector mnemonic. // testMnemonicValue is the standard BIP39 test vector mnemonic.
@@ -22,7 +21,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about" "abandon abandon abandon abandon abandon about"
var ( var (
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set") errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock") errNotImplementedInMock = errors.New("not implemented in mock")
) )
@@ -32,6 +31,7 @@ type MockVault struct {
fs afero.Fs fs afero.Fs
directory string directory string
derivationIndex uint32 derivationIndex uint32
mnemonic *memguard.LockedBuffer
} }
func (m *MockVault) GetDirectory() (string, error) { func (m *MockVault) GetDirectory() (string, error) {
@@ -61,12 +61,11 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
ltPubKeyPath := filepath.Join(m.directory, "pub.age") ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index // Derive long-term key using the vault's derivation index
mnemonic := os.Getenv(EnvMnemonic) if m.mnemonic == nil {
if mnemonic == "" {
return errMnemonicNotSet return errMnemonicNotSet
} }
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex) ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil { if err != nil {
return err return err
} }
@@ -107,6 +106,16 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, errNotImplementedInMock return nil, errNotImplementedInMock
} }
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
m.mnemonic = mnemonic
}
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVault) CreatePassphraseUnlocker( func (m *MockVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer, _ *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) { ) (*PassphraseUnlocker, error) {
@@ -234,13 +243,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
} }
} }
//nolint:paralleltest // uses t.Setenv (process-global environment) //nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) { func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing // Create an in-memory filesystem for testing
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set test mnemonic for direct encryption/decryption mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
t.Setenv(EnvMnemonic, testMnemonicValue) defer mnemonic.Destroy()
// Set up a test vault structure // Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret" baseDir := "/test-config/berlin.sneak.pkg.secret"
@@ -254,6 +263,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
fs: fs, fs: fs,
directory: vaultDir, directory: vaultDir,
derivationIndex: 0, derivationIndex: 0,
mnemonic: mnemonic,
} }
// Test data // Test data
@@ -309,27 +319,3 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
t.Logf("Secret.Exists() works correctly") t.Logf("Secret.Exists() works correctly")
}) })
} }
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
// This test demonstrates the bug where GetValue uses hardcoded index 0
// instead of the vault's actual derivation index when using environment mnemonic
// Set up test mnemonic
t.Setenv(EnvMnemonic, testMnemonicValue)
// Create temporary directory for vaults
fs := afero.NewOsFs()
tempDir, err := afero.TempDir(fs, "", "secret-test-")
require.NoError(t, err)
defer func() {
_ = fs.RemoveAll(tempDir)
}()
stateDir := filepath.Join(tempDir, ".secret")
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
// This test is now in the integration test file where it can use real vaults
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
t.Log("This test demonstrates the bug in the integration test file")
}
+125 -108
View File
@@ -1,10 +1,10 @@
//go:build darwin //go:build darwin
// +build darwin
package secret package secret
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -13,7 +13,6 @@ import (
"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"
) )
@@ -32,6 +31,7 @@ const (
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data. // SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
type SecureEnclaveUnlockerMetadata struct { type SecureEnclaveUnlockerMetadata struct {
UnlockerMetadata UnlockerMetadata
SEKeyLabel string `json:"seKeyLabel"` SEKeyLabel string `json:"seKeyLabel"`
SEKeyHash string `json:"seKeyHash"` SEKeyHash string `json:"seKeyHash"`
} }
@@ -43,6 +43,19 @@ type SecureEnclaveUnlocker struct {
fs afero.Fs fs afero.Fs
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for SE-based unlockers. // GetIdentity implements Unlocker interface for SE-based unlockers.
// Decrypts the vault's long-term private key directly using the Secure Enclave. // Decrypts the vault's long-term private key directly using the Secure Enclave.
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) { func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
@@ -58,6 +71,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Read ECIES-encrypted long-term private key from disk // Read ECIES-encrypted long-term private key from disk
encryptedPath := filepath.Join(s.Directory, seLongtermFilename) encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
encryptedData, err := afero.ReadFile(s.fs, encryptedPath) encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -116,17 +130,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory return s.Directory
} }
// GetID implements Unlocker interface. // GetID implements Unlocker interface: the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
hostname, err := os.Hostname() return filepath.Base(s.Directory)
if err != nil {
hostname = "unknown"
}
createdAt := s.Metadata.CreatedAt
timestamp := createdAt.Format("2006-01-02.15.04")
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
} }
// Remove implements Unlocker interface. // Remove implements Unlocker interface.
@@ -140,7 +146,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
if seKeyHash != "" { if seKeyHash != "" {
Debug("Deleting SE key", "hash", seKeyHash) Debug("Deleting SE key", "hash", seKeyHash)
if err := macse.DeleteKey(seKeyHash); err != nil {
err = macse.DeleteKey(seKeyHash)
if err != nil {
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash) Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
return fmt.Errorf("failed to delete SE key: %w", err) return fmt.Errorf("failed to delete SE key: %w", err)
@@ -148,7 +156,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
} }
Debug("Removing SE unlocker directory", "directory", s.Directory) Debug("Removing SE unlocker directory", "directory", s.Directory)
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
err = RemoveDirAtomic(s.fs, s.Directory)
if err != nil {
return fmt.Errorf("failed to remove SE unlocker directory: %w", err) return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
} }
@@ -158,34 +168,24 @@ func (s *SecureEnclaveUnlocker) Remove() error {
} }
// getSEKeyInfo reads the SE key label and hash from metadata. // getSEKeyInfo reads the SE key label and hash from metadata.
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) { func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json") metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
metadataData, err := afero.ReadFile(s.fs, metadataPath) metadataData, err := afero.ReadFile(s.fs, metadataPath)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to read SE metadata: %w", err) return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
} }
var seMetadata SecureEnclaveUnlockerMetadata var seMetadata SecureEnclaveUnlockerMetadata
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
err = json.Unmarshal(metadataData, &seMetadata)
if err != nil {
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err) return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
} }
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
} }
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
func NewSecureEnclaveUnlocker(
fs afero.Fs,
directory string,
metadata UnlockerMetadata,
) *SecureEnclaveUnlocker {
return &SecureEnclaveUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// generateSEKeyLabel generates a unique label for the SE CTK identity. // generateSEKeyLabel generates a unique label for the SE CTK identity.
func generateSEKeyLabel(vaultName string) (string, error) { func generateSEKeyLabel(vaultName string) (string, error) {
hostname, err := os.Hostname() hostname, err := os.Hostname()
@@ -193,25 +193,31 @@ func generateSEKeyLabel(vaultName string) (string, error) {
return "", fmt.Errorf("failed to get hostname: %w", err) return "", fmt.Errorf("failed to get hostname: %w", err)
} }
enrollmentDate := time.Now().UTC().Format("2006-01-02") enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
return fmt.Sprintf( return fmt.Sprintf(
"%s.%s-%s-%s", "%s.%s-%s-%s",
seKeyLabelPrefix, seKeyLabelPrefix,
vaultName, vaultName,
hostname, hostname,
enrollmentDate, enrollmentTime,
), nil ), 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.
// The SE key is created once the long-term key is in hand and the unlocker's
// path is known, and is deleted again if a later step fails.
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(
fs afero.Fs, fs afero.Fs,
stateDir string, stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil { err := checkMacOSAvailable()
if err != nil {
return nil, err return nil, err
} }
@@ -226,17 +232,8 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to generate SE key label: %w", err) return nil, fmt.Errorf("failed to generate SE key label: %w", err)
} }
// Step 1: Create P-256 key in the Secure Enclave via sc_auth // Step 1: Get the vault's long-term private key
Debug("Creating Secure Enclave key", "label", seKeyLabel) ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key
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",
@@ -245,7 +242,50 @@ func CreateSecureEnclaveUnlocker(
} }
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
// Step 3: Encrypt the long-term key directly with the SE (ECIES) // Step 2: Prepare the unlocker directory's path
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
if err != nil {
return nil, fmt.Errorf("failed to create SE key: %w", err)
}
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
ltPrivKeyData)
if err != nil {
deleteErr := macse.DeleteKey(seKeyHash)
if deleteErr != nil {
err = errors.Join(err, fmt.Errorf(
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
}
return nil, err
}
return unlocker, nil
}
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
// new unlocker into unlockerDir (steps 4 and 5 of
// CreateSecureEnclaveUnlocker).
func writeSEUnlocker(
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
ltPrivKeyData *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
// prepare the metadata
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes()) encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -254,36 +294,11 @@ func CreateSecureEnclaveUnlocker(
) )
} }
// Step 4: Create unlocker directory and write files
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf(
"failed to create unlocker directory: %w",
err,
)
}
// Write SE-encrypted long-term key
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
return nil, fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
// Write metadata
seMetadata := SecureEnclaveUnlockerMetadata{ seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType, Type: seUnlockerType,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
Flags: []string{seUnlockerType, "macos"}, Flags: []string{seUnlockerType, macOSFlag},
}, },
SEKeyLabel: seKeyLabel, SEKeyLabel: seKeyLabel,
SEKeyHash: seKeyHash, SEKeyHash: seKeyHash,
@@ -294,9 +309,12 @@ func CreateSecureEnclaveUnlocker(
return nil, fmt.Errorf("failed to marshal metadata: %w", err) return nil, fmt.Errorf("failed to marshal metadata: %w", err)
} }
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") // Step 5: Write the SE-encrypted long-term key, then the metadata
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil { err = WriteDir(fs, unlockerDir, func(dir string) error {
return nil, fmt.Errorf("failed to write metadata: %w", err) return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
})
if err != nil {
return nil, err
} }
return &SecureEnclaveUnlocker{ return &SecureEnclaveUnlocker{
@@ -306,45 +324,39 @@ func CreateSecureEnclaveUnlocker(
}, nil }, nil
} }
// getLongTermKeyForSE retrieves the vault's long-term private key // writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
// either from the mnemonic env var or by unlocking via the current unlocker. // SE-encrypted long-term key, then the metadata.
func writeSEUnlockerFiles(
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
) error {
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
encryptedLtKey)
if err != nil {
return fmt.Errorf(
"failed to write SE-encrypted long-term key: %w",
err,
)
}
err = WriteFileAtomic(fs,
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write metadata: %w", err)
}
return nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker.
func getLongTermKeyForSE( func getLongTermKeyForSE(
fs afero.Fs, fs afero.Fs,
vault VaultInterface, vault VaultInterface,
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) { ) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic) if mnemonic != nil {
if envMnemonic != "" { return deriveLongTermPrivateKey(fs, vault, mnemonic)
// 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 memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
} }
currentUnlocker, err := vault.GetCurrentUnlocker() currentUnlocker, err := vault.GetCurrentUnlocker()
@@ -352,6 +364,10 @@ func getLongTermKeyForSE(
return nil, fmt.Errorf("failed to get current unlocker: %w", err) return nil, fmt.Errorf("failed to get current unlocker: %w", err)
} }
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
currentIdentity, err := currentUnlocker.GetIdentity() currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
@@ -365,6 +381,7 @@ func getLongTermKeyForSE(
currentUnlocker.GetDirectory(), currentUnlocker.GetDirectory(),
"longterm.age", "longterm.age",
) )
encryptedLtKey, err := afero.ReadFile(fs, longtermPath) encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
+5 -2
View File
@@ -4,8 +4,10 @@ package secret
import ( import (
"errors" "errors"
"path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -66,9 +68,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory return s.Directory
} }
// GetID returns the unlocker ID. // GetID returns the unlocker ID, the name of the unlocker's directory.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType return filepath.Base(s.Directory)
} }
// Remove returns an error on non-Darwin platforms. // Remove returns an error on non-Darwin platforms.
@@ -80,6 +82,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
func CreateSecureEnclaveUnlocker( func CreateSecureEnclaveUnlocker(
_ afero.Fs, _ afero.Fs,
_ string, _ string,
_, _ *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported return nil, errSENotSupported
} }
+3 -4
View File
@@ -35,9 +35,8 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
// Test GetDirectory returns the directory we passed in // Test GetDirectory returns the directory we passed in
assert.Equal(t, dir, unlocker.GetDirectory()) assert.Equal(t, dir, unlocker.GetDirectory())
// Test GetID returns a formatted string with the creation timestamp // Test GetID returns the name of the unlocker's directory
expectedID := "2026-01-15.10.30-secure-enclave" assert.Equal(t, "test-se-unlocker", unlocker.GetID())
assert.Equal(t, expectedID, unlocker.GetID())
} }
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) { func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
@@ -78,7 +77,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test") unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
assert.Nil(t, unlocker) assert.Nil(t, unlocker)
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported) require.ErrorIs(t, err, errSENotSupported)
+22 -12
View File
@@ -1,6 +1,6 @@
//go:build darwin //go:build darwin
// +build darwin
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
package secret package secret
import ( import (
@@ -13,12 +13,14 @@ import (
) )
func TestNewSecureEnclaveUnlocker(t *testing.T) { func TestNewSecureEnclaveUnlocker(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker" dir := "/tmp/test-se-unlocker"
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
Flags: []string{"secure-enclave", "macos"}, Flags: []string{seUnlockerType, "macos"},
} }
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata) unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
@@ -35,9 +37,11 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
} }
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) { func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Now().UTC(), CreatedAt: time.Now().UTC(),
} }
@@ -48,21 +52,23 @@ func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
} }
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) { func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
} }
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// ID should contain the timestamp and "secure-enclave" type // The ID is the name of the unlocker's directory
assert.Contains(t, id, "2026-03-10.14.30") assert.Equal(t, "test", unlocker.GetID())
assert.Contains(t, id, seUnlockerType)
} }
func TestGenerateSEKeyLabel(t *testing.T) { func TestGenerateSEKeyLabel(t *testing.T) {
t.Parallel()
label, err := generateSEKeyLabel("test-vault") label, err := generateSEKeyLabel("test-vault")
require.NoError(t, err) require.NoError(t, err)
@@ -72,6 +78,8 @@ func TestGenerateSEKeyLabel(t *testing.T) {
} }
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) { func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
dir := "/tmp/test-se-unlocker-missing" dir := "/tmp/test-se-unlocker-missing"
@@ -84,10 +92,12 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
"seKeyLabel": "berlin.sneak.app.secret.se.test", "seKeyLabel": "berlin.sneak.app.secret.se.test",
"seKeyHash": "abc123" "seKeyHash": "abc123"
}` }`
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms)) require.NoError(t, afero.WriteFile(
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
))
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: "secure-enclave", Type: seUnlockerType,
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC), CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
} }
@@ -96,6 +106,6 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
// GetIdentity should fail because the encrypted longterm key file is missing // GetIdentity should fail because the encrypted longterm key file is missing
identity, err := unlocker.GetIdentity() identity, err := unlocker.GetIdentity()
assert.Nil(t, identity) assert.Nil(t, identity)
assert.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key") assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
} }
+1 -1
View File
@@ -10,6 +10,6 @@ type Unlocker interface {
GetType() string GetType() string
GetMetadata() UnlockerMetadata GetMetadata() UnlockerMetadata
GetDirectory() string GetDirectory() string
GetID() string // Generate ID based on unlocker type and data GetID() string // The name of the unlocker's directory, unique in its vault
Remove() error // Remove the unlocker and any associated resources Remove() error // Remove the unlocker and any associated resources
} }
+29 -120
View File
@@ -1,5 +1,6 @@
//go:build darwin //go:build darwin
//nolint:testpackage // white-box test of unexported validateKeychainItemName
package secret package secret
import ( import (
@@ -7,138 +8,46 @@ 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 simple name", {name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
itemName: "my-secret-key", {name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
wantErr: false, {name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
}, {name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
{ {name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
name: "valid 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: "empty item name", {name: "item name with spaces", itemName: "my secret key", wantErr: true},
itemName: "", {name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
wantErr: true, {name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
}, {name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
{ {name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
name: "item name with spaces", {name: "item name with quotes", itemName: "key\"name", wantErr: true},
itemName: "my secret key", {name: "item name with single quotes", itemName: "key'name", wantErr: true},
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 semicolon", {name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
itemName: "key;rm -rf /", {name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
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 pipe", {name: "item name with asterisk", itemName: "key*", wantErr: true},
itemName: "key|cat /etc/passwd", {name: "item name with question mark", itemName: "key?", 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 single quotes",
itemName: "key'name",
wantErr: true,
},
{
name: "item name with backslash",
itemName: "key\\name",
wantErr: true,
},
{
name: "item name with newline",
itemName: "key\nname",
wantErr: true,
},
{
name: "item name with carriage return",
itemName: "key\rname",
wantErr: true,
},
{
name: "item name with ampersand",
itemName: "key&echo test",
wantErr: true,
},
{
name: "item name with redirect",
itemName: "key>/tmp/test",
wantErr: true,
},
{
name: "item name with null byte",
itemName: "key\x00name",
wantErr: true,
},
{
name: "item name with parentheses",
itemName: "key(test)",
wantErr: true,
},
{
name: "item name with brackets",
itemName: "key[test]",
wantErr: true,
},
{
name: "item name with asterisk",
itemName: "key*",
wantErr: true,
},
{
name: "item name with question mark",
itemName: "key?",
wantErr: true,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateKeychainItemName(tt.itemName) err := validateKeychainItemName(tt.itemName)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
+8 -5
View File
@@ -175,9 +175,7 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
return fmt.Errorf("failed to generate version keypair: %w", err) return fmt.Errorf("failed to generate version keypair: %w", err)
} }
// Store private key in memguard buffer immediately versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
[]byte(versionIdentity.String()))
defer versionPrivateKeyBuffer.Destroy() defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair", DebugWith("Generated version keypair",
@@ -559,13 +557,18 @@ func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error)
} }
// GetCurrentVersion returns the version that the "current" file points to // GetCurrentVersion returns the version that the "current" file points to
// The file contains just the version name (e.g., "20231215.001") // The file contains just the version name (e.g., "20231215.001"). If it
// cannot be read, the error says how to make a version current again: the
// versions themselves are not in the file.
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) { func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
currentPath := filepath.Join(secretDir, "current") currentPath := filepath.Join(secretDir, "current")
fileData, err := afero.ReadFile(fs, currentPath) fileData, err := afero.ReadFile(fs, currentPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read current version file: %w", err) return "", fmt.Errorf("failed to read current version file: %w; "+
"this file only names the current version: 'secret version list' "+
"lists the secret's versions, and 'secret version promote' makes "+
"one of them current", err)
} }
version := strings.TrimSpace(string(fileData)) version := strings.TrimSpace(string(fileData))
+8
View File
@@ -87,6 +87,14 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
return nil, errNotImplementedInMock return nil, errNotImplementedInMock
} }
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
return nil, errNotImplementedInMock
}
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
func (m *MockVersionVault) CreatePassphraseUnlocker( func (m *MockVersionVault) CreatePassphraseUnlocker(
_ *memguard.LockedBuffer, _ *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) { ) (*secret.PassphraseUnlocker, error) {
+9 -3
View File
@@ -17,9 +17,10 @@ var (
"derived public key does not match vault: mnemonic may be incorrect", "derived public key does not match vault: mnemonic may be incorrect",
) )
// ErrInvalidVaultName indicates a vault name that does not match the // ErrInvalidVaultName indicates a vault name that breaks the naming
// allowed pattern [a-z0-9.\-_]+. Composed as // rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+". // empty, "." or "..". Composed by ValidateVaultName as
// "invalid vault name '<name>': <the rule>".
ErrInvalidVaultName = errors.New("invalid vault name") ErrInvalidVaultName = errors.New("invalid vault name")
// ErrVaultNotFound indicates the named vault does not exist. Composed // ErrVaultNotFound indicates the named vault does not exist. Composed
@@ -30,6 +31,11 @@ var (
// Composed as "vault <name> already exists". // Composed as "vault <name> already exists".
ErrVaultExists = errors.New("already exists") ErrVaultExists = errors.New("already exists")
// ErrUnlockerWithoutMnemonic indicates that CreateVault was given a
// passphrase for an unlocker but no mnemonic to derive the long-term key
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
// ErrNilValueBuffer indicates a nil value buffer was supplied. // ErrNilValueBuffer indicates a nil value buffer was supplied.
ErrNilValueBuffer = errors.New("value buffer is nil") ErrNilValueBuffer = errors.New("value buffer is nil")
+25 -20
View File
@@ -8,7 +8,6 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -41,46 +40,49 @@ func deriveVaultIdentity(
return ltIdentity return ltIdentity
} }
//nolint:paralleltest // t.Setenv forbids parallel subtests
func TestVaultWithRealFilesystem(t *testing.T) { func TestVaultWithRealFilesystem(t *testing.T) {
t.Parallel()
// Create a temporary directory for our tests // Create a temporary directory for our tests
tempDir := t.TempDir() tempDir := t.TempDir()
// Use the real filesystem // Use the real filesystem
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Test currentvault file handling (plain file with relative path) // Test currentvault file handling (plain file with relative path)
t.Run("CurrentVaultFileHandling", func(t *testing.T) { t.Run("CurrentVaultFileHandling", func(t *testing.T) {
t.Parallel()
testCurrentVaultFileHandling(t, fs, tempDir) testCurrentVaultFileHandling(t, fs, tempDir)
}) })
// Test secret operations with deeply nested paths // Test secret operations with deeply nested paths
t.Run("DeepPathSecrets", func(t *testing.T) { t.Run("DeepPathSecrets", func(t *testing.T) {
t.Parallel()
testDeepPathSecrets(t, fs, tempDir) testDeepPathSecrets(t, fs, tempDir)
}) })
// Test key caching in GetOrDeriveLongTermKey // Test key caching in GetOrDeriveLongTermKey
t.Run("KeyCaching", func(t *testing.T) { t.Run("KeyCaching", func(t *testing.T) {
t.Parallel()
testKeyCaching(t, fs, tempDir) testKeyCaching(t, fs, tempDir)
}) })
// Test vault name validation // Test vault name validation
t.Run("VaultNameValidation", func(t *testing.T) { t.Run("VaultNameValidation", func(t *testing.T) {
t.Parallel()
testVaultNameValidation(t, fs, tempDir) testVaultNameValidation(t, fs, tempDir)
}) })
// Test multiple vaults and switching between them // Test multiple vaults and switching between them
t.Run("MultipleVaults", func(t *testing.T) { t.Run("MultipleVaults", func(t *testing.T) {
t.Parallel()
testMultipleVaults(t, fs, tempDir) testMultipleVaults(t, fs, tempDir)
}) })
// Test adding a secret in one vault and verifying it's not visible in // Test adding a secret in one vault and verifying it's not visible in
// another // another
t.Run("VaultIsolation", func(t *testing.T) { t.Run("VaultIsolation", func(t *testing.T) {
t.Parallel()
testVaultIsolation(t, fs, tempDir) testVaultIsolation(t, fs, tempDir)
}) })
} }
@@ -96,7 +98,8 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
} }
// Create a test vault // Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName) vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -141,9 +144,10 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault - CreateVault now handles public key when // Create a test vault - CreateVault writes the public key derived from
// mnemonic is in env // the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName) vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -216,9 +220,10 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create a test vault - CreateVault now handles public key when // Create a test vault - CreateVault writes the public key derived from
// mnemonic is in env // the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName) vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -319,7 +324,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
} }
for _, name := range validNames { for _, name := range validNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Errorf("Failed to create vault with valid name %q: %v", name, err) t.Errorf("Failed to create vault with valid name %q: %v", name, err)
} }
@@ -335,7 +340,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
} }
for _, name := range invalidNames { for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err == nil { if err == nil {
t.Errorf("Expected error creating vault with invalid name %q, "+ t.Errorf("Expected error creating vault with invalid name %q, "+
"but got none", name) "but got none", name)
@@ -356,7 +361,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
// Create three vaults // Create three vaults
vaultNames := []string{"vault1", "vault2", "vault3"} vaultNames := []string{"vault1", "vault2", "vault3"}
for _, name := range vaultNames { for _, name := range vaultNames {
_, err := vault.CreateVault(fs, stateDir, name) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault %s: %v", name, err) t.Fatalf("Failed to create vault %s: %v", name, err)
} }
@@ -404,14 +409,14 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
t.Fatalf("Failed to create state dir: %v", err) t.Fatalf("Failed to create state dir: %v", err)
} }
// Create two vaults - CreateVault now handles public key when mnemonic // Create two vaults - CreateVault writes the public key derived from
// is in env // the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1") vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault1: %v", err) t.Fatalf("Failed to create vault1: %v", err)
} }
vault2, err := vault.CreateVault(fs, stateDir, "vault2") vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault2: %v", err) t.Fatalf("Failed to create vault2: %v", err)
} }
+9 -10
View File
@@ -44,15 +44,12 @@ var errUnexpectedValue = errors.New("unexpected value")
// TestVersionIntegrationWorkflow tests the complete version workflow // TestVersionIntegrationWorkflow tests the complete version workflow
// //
//nolint:paralleltest // t.Setenv forbids parallel subtests //nolint:paralleltest // the subtests are steps that build on each other
func TestVersionIntegrationWorkflow(t *testing.T) { func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set mnemonic for testing // Create vault without a long-term key, which is set up below
t.Setenv(secret.EnvMnemonic, testMnemonic) vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -351,9 +348,9 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
} }
// TestVersionConcurrency tests concurrent version operations // TestVersionConcurrency tests concurrent version operations
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionConcurrency(t *testing.T) { func TestVersionConcurrency(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set up vault // Set up vault
@@ -366,6 +363,8 @@ func TestVersionConcurrency(t *testing.T) {
// Test concurrent reads // Test concurrent reads
t.Run("concurrent_reads", func(t *testing.T) { t.Run("concurrent_reads", func(t *testing.T) {
t.Parallel()
done := make(chan bool, 10) done := make(chan bool, 10)
errCh := make(chan error, 10) errCh := make(chan error, 10)
@@ -403,9 +402,9 @@ func TestVersionConcurrency(t *testing.T) {
} }
// TestVersionCompatibility tests that old secrets without versions still work // TestVersionCompatibility tests that old secrets without versions still work
//
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVersionCompatibility(t *testing.T) { func TestVersionCompatibility(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Set up vault // Set up vault
+95 -2
View File
@@ -1,6 +1,7 @@
package vault package vault
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -14,6 +15,11 @@ import (
// lockFileName is the file in the state directory that LockStateDir locks. // lockFileName is the file in the state directory that LockStateDir locks.
const lockFileName = "lock" const lockFileName = "lock"
// finishedMark is what the lock file holds once the command that last held
// the lock has released it. A command killed while holding it leaves the
// file empty.
const finishedMark = "finished\n"
// memFsLock stands in for the lock file on the in-memory filesystem, which // memFsLock stands in for the lock file on the in-memory filesystem, which
// has no file locks. Every in-memory filesystem in the process shares it. // has no file locks. Every in-memory filesystem in the process shares it.
// //
@@ -25,6 +31,12 @@ var memFsLock sync.Mutex
// it. While one command holds it, the next one waits here. Reads take no // it. While one command holds it, the next one waits here. Reads take no
// lock: each file or directory a command changes is replaced in a single // lock: each file or directory a command changes is replaced in a single
// rename, so a reader finds it as it was before or after, never half-made. // rename, so a reader finds it as it was before or after, never half-made.
// Once it holds the lock, it empties the lock file, and the function it
// returns writes finishedMark there just before releasing the lock, so a
// command killed while holding the lock leaves the mark missing. Finding it
// missing, LockStateDir first deletes the temporary files and directories
// such a command may have left, since no command still using them can be
// running. After a command that finished, it searches nothing.
// //
// On the real filesystem the lock is flock(2) on the file "lock" in // On the real filesystem the lock is flock(2) on the file "lock" in
// stateDir, which the kernel releases when the process dies, so a killed // stateDir, which the kernel releases when the process dies, so a killed
@@ -32,16 +44,97 @@ var memFsLock sync.Mutex
// use has no file locks, so a process-wide mutex stands in for flock there. // use has no file locks, so a process-wide mutex stands in for flock there.
// Any other filesystem is refused rather than left unlocked. // Any other filesystem is refused rather than left unlocked.
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) { func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
var release func()
switch fs.(type) { switch fs.(type) {
case *afero.OsFs: case *afero.OsFs:
return flockStateDir(stateDir) var err error
release, err = flockStateDir(stateDir)
if err != nil {
return nil, err
}
case *afero.MemMapFs: case *afero.MemMapFs:
memFsLock.Lock() memFsLock.Lock()
return memFsLock.Unlock, nil release = memFsLock.Unlock
default: default:
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs) return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
} }
// The lock file is written in place, never replaced: a command waiting
// for flock on the old file would then take a lock nobody else checks.
lockPath := filepath.Join(stateDir, lockFileName)
mark, err := afero.ReadFile(fs, lockPath)
if err != nil || string(mark) != finishedMark {
removeLeftovers(fs, stateDir)
}
err = afero.WriteFile(fs, lockPath, nil, secret.FilePerms)
if err != nil {
release()
return nil, fmt.Errorf("failed to empty lock file %s: %w", lockPath, err)
}
return func() {
// If this fails, the next command searches when it need not.
_ = afero.WriteFile(fs, lockPath, []byte(finishedMark), secret.FilePerms)
release()
}, nil
}
// removeLeftovers deletes the temporary files and directories that commands
// killed part-way left in each directory where secret.WriteFileAtomic and
// secret.TempDirFor make them: the state directory, each vault, each secret
// and each version. Unlocker directories are written whole by
// secret.WriteDir and never changed after, so they hold none. A failure is
// only warned about, and the command goes on.
func removeLeftovers(fs afero.Fs, stateDir string) {
dirs := []string{stateDir}
for _, vaultDir := range subdirs(fs, filepath.Join(stateDir, "vaults.d")) {
dirs = append(dirs, vaultDir)
for _, secretDir := range subdirs(fs, filepath.Join(vaultDir, "secrets.d")) {
dirs = append(dirs, secretDir)
dirs = append(dirs, subdirs(fs, filepath.Join(secretDir, "versions"))...)
}
}
for _, dir := range dirs {
err := secret.RemoveLeftovers(fs, dir)
if err != nil {
secret.Warn("Failed to remove what an interrupted command left",
"error", err)
}
}
}
// subdirs returns the directories in dir: none if dir does not exist, and
// none, with a warning, if it cannot be read.
func subdirs(fs afero.Fs, dir string) []string {
entries, err := afero.ReadDir(fs, dir)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
secret.Warn("Failed to look for what an interrupted command left",
"directory", dir, "error", err)
}
return nil
}
var dirs []string
for _, entry := range entries {
if entry.IsDir() {
dirs = append(dirs, filepath.Join(dir, entry.Name()))
}
}
return dirs
} }
// flockStateDir takes flock(2) on the lock file in stateDir, creating the // flockStateDir takes flock(2) on the lock file in stateDir, creating the
+47
View File
@@ -1,9 +1,11 @@
package vault_test package vault_test
import ( import (
"path/filepath"
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -121,6 +123,51 @@ func TestLockStateDirFreeAfterPanic(t *testing.T) {
} }
} }
// TestLockStateDirRemovesLeftoversOnlyAfterKill checks that taking the lock
// deletes a temporary directory a killed command left only when the last
// holder of the lock did not release it. A holder killed while it holds the
// lock leaves the lock file as it is at that moment.
func TestLockStateDirRemovesLeftoversOnlyAfterKill(t *testing.T) {
t.Parallel()
for _, lfs := range lockFilesystems(t) {
t.Run(lfs.name, func(t *testing.T) {
t.Parallel()
lockFile := filepath.Join(lfs.stateDir, "lock")
leftover := filepath.Join(lfs.stateDir, ".tmp-1")
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
whileHeld, err := afero.ReadFile(lfs.fs, lockFile)
require.NoError(t, err)
release()
require.NoError(t, lfs.fs.MkdirAll(leftover, secret.DirPerms))
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
release()
exists, err := afero.DirExists(lfs.fs, leftover)
require.NoError(t, err)
assert.True(t, exists, "searched after a holder that finished")
require.NoError(t, afero.WriteFile(lfs.fs, lockFile, whileHeld,
secret.FilePerms))
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
require.NoError(t, err)
release()
exists, err = afero.DirExists(lfs.fs, leftover)
require.NoError(t, err)
assert.False(t, exists, "not searched after a holder that was killed")
})
}
}
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no // TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
// lock implementation is refused instead of being used unlocked. // lock implementation is refused instead of being used unlocked.
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) { func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
+117 -77
View File
@@ -3,14 +3,15 @@ package vault
import ( import (
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"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"
) )
@@ -24,10 +25,12 @@ func init() {
}) })
} }
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+ // isValidVaultName reports whether name is a valid vault name: only
// Note: We don't allow slashes in vault names unlike secret names // lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
// "..". With no path separator allowed, a vault is always one directory
// directly under vaults.d.
func isValidVaultName(name string) bool { func isValidVaultName(name string) bool {
if name == "" { if name == "" || name == "." || name == ".." {
return false return false
} }
@@ -36,6 +39,21 @@ func isValidVaultName(name string) bool {
return matched return matched
} }
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
// is not a valid vault name. Call it on the name exactly as the user gave it,
// before building any path from it.
func ValidateVaultName(name string) error {
if !isValidVaultName(name) {
return fmt.Errorf(
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
"are allowed, and a name must not be empty, '.' or '..'",
ErrInvalidVaultName, name,
)
}
return nil
}
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault // ResolveVaultSymlink reads the currentvault file to get the path to the current vault
// The file contains just the vault name (e.g., "default") // The file contains just the vault name (e.g., "default")
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) { func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
@@ -135,33 +153,34 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
} }
// processMnemonicForVault handles mnemonic processing for vault creation. // processMnemonicForVault handles mnemonic processing for vault creation.
// It returns the derivation index, public key hash, and family hash. // It returns the long-term key, nil when there is no mnemonic, and 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,
) (uint32, string, string, error) { mnemonicBuffer *memguard.LockedBuffer,
// Check if mnemonic is available in environment ) (*age.X25519Identity, uint32, string, string, error) {
mnemonic := os.Getenv(secret.EnvMnemonic) if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key",
if mnemonic == "" {
secret.Debug("No mnemonic in environment, vault created without long-term key",
"vault", vaultName) "vault", vaultName)
// Use 0 for derivation index when no mnemonic is provided // Use 0 for derivation index when no mnemonic is provided
return 0, "", "", nil return nil, 0, "", "", nil
} }
secret.Debug("Mnemonic found in environment, deriving long-term key", mnemonic := mnemonicBuffer.String()
"vault", vaultName)
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
// Get the next available derivation index for this mnemonic // Get the next available derivation index for this mnemonic
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic) derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
if err != nil { if err != nil {
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err) return nil, 0, "", "",
fmt.Errorf("failed to get next derivation index: %w", err)
} }
// Derive the long-term key using the actual derivation index // Derive the long-term key using the actual derivation index
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
if err != nil { if err != nil {
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err) return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
} }
// Write the public key // Write the public key
@@ -171,7 +190,8 @@ func processMnemonicForVault(
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey)) err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
if err != nil { if err != nil {
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err) return nil, 0, "", "",
fmt.Errorf("failed to write long-term public key: %w", err)
} }
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath) secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
@@ -183,30 +203,41 @@ func processMnemonicForVault(
// This is used to identify which vaults belong to the same mnemonic family // This is used to identify which vaults belong to the same mnemonic family
identity0, err := agehd.DeriveIdentity(mnemonic, 0) identity0, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil { if err != nil {
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err) return nil, 0, "", "",
fmt.Errorf("failed to derive identity for index 0: %w", err)
} }
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String())) familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
return derivationIndex, publicKeyHash, familyHash, nil return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
} }
// CreateVault creates a new vault and selects it as the current vault. It // CreateVault creates a new vault and selects it as the current vault. When
// refuses a vault that already exists before writing anything: creating it // mnemonic is not nil, the vault's long-term key is derived from it, and the
// again would replace its keys, and its secrets could no longer be // returned vault has it as its Mnemonic; when it is nil, the vault has no
// decrypted. The commands that call it hold the state directory lock, so no // long-term key until one is imported. When passphrase is not nil, the vault
// other command can create the vault between the check and the writes. // gets a passphrase unlocker protected by it, as its current unlocker; that
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) { // needs a mnemonic. It refuses a vault that already exists before writing
// anything: creating it again would replace its keys, and its secrets could
// no longer be decrypted. The commands that call it hold the state directory
// lock, so no other command can create the vault between the check and the
// writes.
//
// The vault is written whole into a temporary directory, which is renamed
// into vaults.d only once complete, and only then selected: a crash at any
// point leaves either no vault or a complete one. The next command that
// takes the lock deletes what the crash left under a temporary name.
func CreateVault(
fs afero.Fs, stateDir string, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
// Validate vault name err := ValidateVaultName(name)
if !isValidVaultName(name) { if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name) secret.Debug("Invalid vault name provided", "vault_name", name)
return nil, fmt.Errorf( return nil, err
"%w '%s': must match pattern [a-z0-9.\\-_]+",
ErrInvalidVaultName, name,
)
} }
secret.Debug("Vault name validation passed", "vault_name", name) secret.Debug("Vault name validation passed", "vault_name", name)
@@ -222,51 +253,19 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists) return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
} }
// Create vault directory structure if passphrase != nil && mnemonic == nil {
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
}
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir) secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
// Create main vault directory err = secret.WriteDir(fs, vaultDir, func(dir string) error {
err = fs.MkdirAll(vaultDir, secret.DirPerms) return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
if err != nil { })
return nil, fmt.Errorf("failed to create vault directory: %w", err)
}
// Create secrets directory
secretsDir := filepath.Join(vaultDir, "secrets.d")
err = fs.MkdirAll(secretsDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
}
// Create unlockers directory
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
if err != nil {
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
}
// Process mnemonic if available
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
fs, stateDir, vaultDir, name)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Save vault metadata
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
}
// Select the newly created vault as current // Select the newly created vault as current
secret.Debug("Selecting newly created vault as current", "name", name) secret.Debug("Selecting newly created vault as current", "name", name)
@@ -278,21 +277,62 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
// Create and return the vault // Create and return the vault
secret.Debug("Successfully created vault", "name", name) secret.Debug("Successfully created vault", "name", name)
return NewVault(fs, stateDir, name), nil vlt := NewVault(fs, stateDir, name)
vlt.Mnemonic = mnemonic
return vlt, nil
}
// writeVaultFiles writes the files of the new vault name into vaultDir: its
// secrets and unlockers directories, its long-term public key and metadata,
// and, when passphrase is not nil, a passphrase unlocker as its current one.
func writeVaultFiles(
fs afero.Fs, stateDir, vaultDir, name string,
mnemonic, passphrase *memguard.LockedBuffer,
) error {
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
if err != nil {
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
}
}
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
if err != nil {
return err
}
metadata := &Metadata{
CreatedAt: time.Now(),
DerivationIndex: derivationIndex,
PublicKeyHash: publicKeyHash,
MnemonicFamilyHash: familyHash,
}
err = SaveVaultMetadata(fs, vaultDir, metadata)
if err != nil {
return fmt.Errorf("failed to save vault metadata: %w", err)
}
if passphrase == nil {
return nil
}
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
return err
} }
// SelectVault selects the given vault as the current vault // SelectVault selects the given vault as the current vault
func SelectVault(fs afero.Fs, stateDir string, name string) error { func SelectVault(fs afero.Fs, stateDir string, name string) error {
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir) secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
// Validate vault name err := ValidateVaultName(name)
if !isValidVaultName(name) { if err != nil {
secret.Debug("Invalid vault name provided", "vault_name", name) secret.Debug("Invalid vault name provided", "vault_name", name)
return fmt.Errorf( return err
"%w '%s': must match pattern [a-z0-9.\\-_]+",
ErrInvalidVaultName, name,
)
} }
secret.Debug("Vault name validation passed", "vault_name", name) secret.Debug("Vault name validation passed", "vault_name", name)
+6 -10
View File
@@ -297,14 +297,14 @@ func TestSampleHashCalculation(t *testing.T) {
} }
func TestWorkflowMismatch(t *testing.T) { func TestWorkflowMismatch(t *testing.T) {
t.Parallel()
// Create a temporary directory for testing // Create a temporary directory for testing
tempDir := t.TempDir() tempDir := t.TempDir()
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command) // Test Case 1: Create vault WITH mnemonic (like init command)
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic) _, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
_, err := vault.CreateVault(fs, tempDir, "default")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault with mnemonic: %v", err) t.Fatalf("Failed to create vault with mnemonic: %v", err)
} }
@@ -321,19 +321,15 @@ func TestWorkflowMismatch(t *testing.T) {
metadata1.DerivationIndex, metadata1.PublicKeyHash) metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault) // Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
t.Setenv("SB_SECRET_MNEMONIC", "") _, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
_, err = vault.CreateVault(fs, tempDir, "work")
if err != nil { if err != nil {
t.Fatalf("Failed to create vault without mnemonic: %v", err) t.Fatalf("Failed to create vault without mnemonic: %v", err)
} }
vault2Dir := filepath.Join(tempDir, "vaults.d", "work") vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
// Simulate the vault import process // Simulate the vault import process: get the next available derivation
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic) // index for this mnemonic
// Get the next available derivation index for this mnemonic
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic) derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
if err != nil { if err != nil {
t.Fatalf("Failed to get next derivation index: %v", err) t.Fatalf("Failed to get next derivation index: %v", err)
+13 -14
View File
@@ -3,7 +3,6 @@ package vault_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
@@ -13,15 +12,13 @@ import (
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion // TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
// validates the secret name and rejects path traversal attempts. // validates the secret name and rejects path traversal attempts.
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13 // This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) { func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
// Add a legitimate secret so the vault is set up // Add a legitimate secret so the vault is set up
@@ -41,6 +38,8 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames { for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretVersion(name, "") _, err := vlt.GetSecretVersion(name, "")
require.Error(t, err, require.Error(t, err,
"GetSecretVersion should reject malicious name: %s", name) "GetSecretVersion should reject malicious name: %s", name)
@@ -53,12 +52,12 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls // TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
// GetSecretVersion) also rejects path traversal names. // GetSecretVersion) also rejects path traversal names.
func TestGetSecretRejectsPathTraversal(t *testing.T) { func TestGetSecretRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd") _, err = vlt.GetSecret("../../../etc/passwd")
@@ -68,15 +67,13 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject // TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
// also validates names and rejects path traversal attempts. // also validates names and rejects path traversal attempts.
//
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) { func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Parallel()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
maliciousNames := []string{ maliciousNames := []string{
@@ -87,6 +84,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
for _, name := range maliciousNames { for _, name := range maliciousNames {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
t.Parallel()
_, err := vlt.GetSecretObject(name) _, err := vlt.GetSecretObject(name)
require.Error(t, err, "GetSecretObject should reject: %s", name) require.Error(t, err, "GetSecretObject should reject: %s", name)
require.Contains(t, err.Error(), "invalid secret name") require.Contains(t, err.Error(), "invalid secret name")
+14 -19
View File
@@ -41,14 +41,6 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " + const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon about" "abandon abandon abandon abandon about"
// envTestMnemonic is the (deliberately different) mnemonic placed in the
// environment; the vault is unlocked manually with the derived key in
// createTestVaultWithKey.
//
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon about"
// Shared fixtures for white-box tests in this package. // Shared fixtures for white-box tests in this package.
const ( const (
testStateDir = "/test/state" testStateDir = "/test/state"
@@ -73,11 +65,8 @@ func addTestSecretToVault(
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault { func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper() t.Helper()
// Set mnemonic for testing // Create vault without a long-term key, which is set up below
t.Setenv(secret.EnvMnemonic, envTestMnemonic) vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
// Create vault
vault, err := CreateVault(fs, testStateDir, "test")
require.NoError(t, err) require.NoError(t, err)
// Derive and store long-term key from mnemonic // Derive and store long-term key from mnemonic
@@ -98,8 +87,9 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
return vault return vault
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretCreatesVersion(t *testing.T) { func TestVaultAddSecretCreatesVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -137,8 +127,9 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
assert.Equal(t, expectedValue, retrievedValue.Bytes()) assert.Equal(t, expectedValue, retrievedValue.Bytes())
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultAddSecretMultipleVersions(t *testing.T) { func TestVaultAddSecretMultipleVersions(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -174,8 +165,9 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
assert.Equal(t, []byte("version-2"), value.Bytes()) assert.Equal(t, []byte("version-2"), value.Bytes())
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetSecretVersion(t *testing.T) { func TestVaultGetSecretVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -220,8 +212,9 @@ func TestVaultGetSecretVersion(t *testing.T) {
require.ErrorIs(t, err, ErrVersionNotFound) require.ErrorIs(t, err, ErrVersionNotFound)
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultVersionTimestamps(t *testing.T) { func TestVaultVersionTimestamps(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -303,8 +296,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestVaultGetNonExistentVersion(t *testing.T) { func TestVaultGetNonExistentVersion(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
@@ -319,8 +313,9 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
assert.Contains(t, err.Error(), "not found") assert.Contains(t, err.Error(), "not found")
} }
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
func TestUpdateVersionMetadata(t *testing.T) { func TestUpdateVersionMetadata(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault with long-term key // Create vault with long-term key
+206 -117
View File
@@ -2,8 +2,10 @@ package vault
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
@@ -70,7 +72,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
secret.Debug("Creating passphrase unlocker instance", secret.Debug("Creating passphrase unlocker instance",
"unlocker_type", metadata.Type) "unlocker_type", metadata.Type)
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
passphraseUnlocker.Passphrase = v.UnlockPassphrase
unlocker = passphraseUnlocker
case "pgp": case "pgp":
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type) secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
@@ -101,7 +105,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 unlocker name (e.g., "passphrase") // The file contains just the name of the unlocker's directory in unlockers.d
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) { func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath) secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
@@ -126,7 +130,11 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
} }
// findUnlockerByID finds an unlocker by its ID and returns the unlocker // findUnlockerByID finds an unlocker by its ID and returns the unlocker
// instance and its directory path // instance and its directory path. A directory that ListUnlockers skips is
// skipped here too, with the same warning. Such a directory has no ID: if
// no unlocker has the ID unlockerID but such a directory is named
// unlockerID, that directory is returned with a nil unlocker, so that
// RemoveUnlocker can remove it.
// //
//nolint:ireturn // returns one of several concrete unlocker implementations //nolint:ireturn // returns one of several concrete unlocker implementations
func (v *Vault) findUnlockerByID( func (v *Vault) findUnlockerByID(
@@ -137,42 +145,24 @@ func (v *Vault) findUnlockerByID(
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err) return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
} }
skippedDirPath := ""
for _, file := range files { for _, file := range files {
if !file.IsDir() { if !file.IsDir() {
continue continue
} }
// Read metadata file unlockerDirPath := filepath.Join(unlockersDir, file.Name())
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath) metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if err != nil { if !ok {
return nil, "", fmt.Errorf( if file.Name() == unlockerID {
"failed to check if metadata exists for unlocker %s: %w", skippedDirPath = unlockerDirPath
file.Name(), err) }
}
if !exists {
// Skip directories without metadata - they might not be unlockers
continue continue
} }
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
return nil, "", fmt.Errorf(
"failed to read metadata for unlocker %s: %w", file.Name(), err)
}
var metadata UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
return nil, "", fmt.Errorf(
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
}
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
// Create the appropriate unlocker instance // Create the appropriate unlocker instance
var tempUnlocker secret.Unlocker var tempUnlocker secret.Unlocker
@@ -195,11 +185,12 @@ func (v *Vault) findUnlockerByID(
} }
} }
return nil, "", nil return nil, skippedDirPath, nil
} }
// ListUnlockers returns a list of available unlockers for this vault // ListUnlockers returns the metadata of each unlocker of this vault, keyed
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) { // by the unlocker's ID, the name of its directory in unlockers.d
func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -214,7 +205,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
} }
if !exists { if !exists {
return []UnlockerMetadata{}, nil return map[string]UnlockerMetadata{}, nil
} }
// List directories in unlockers.d // List directories in unlockers.d
@@ -223,55 +214,88 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err) return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
} }
var unlockers []UnlockerMetadata unlockers := map[string]UnlockerMetadata{}
for _, file := range files { for _, file := range files {
if file.IsDir() { if !file.IsDir() {
// Read metadata file continue
metadataPath := filepath.Join(unlockersDir, file.Name(), }
"unlocker-metadata.json")
exists, err := afero.Exists(v.fs, metadataPath) metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if err != nil { if ok {
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked", unlockers[file.Name()] = metadata
"directory", file.Name(), "error", err)
continue
}
if !exists {
secret.Warn("Skipping unlocker directory with missing metadata file",
"directory", file.Name())
continue
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory with unreadable metadata file",
"directory", file.Name(), "error", err)
continue
}
var metadata UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
secret.Warn("Skipping unlocker directory with corrupt metadata file",
"directory", file.Name(), "error", err)
continue
}
unlockers = append(unlockers, metadata)
} }
} }
return unlockers, nil return unlockers, nil
} }
// RemoveUnlocker removes an unlocker from this vault // readUnlockerMetadataOrWarn reads the metadata of the unlocker directory
// name in unlockersDir. If the metadata file cannot be checked for, is
// missing, or cannot be read or parsed, it warns, naming the directory,
// and returns false: the caller skips that directory.
func (v *Vault) readUnlockerMetadataOrWarn(
unlockersDir, name string,
) (UnlockerMetadata, bool) {
metadataPath := filepath.Join(unlockersDir, name, "unlocker-metadata.json")
var metadata UnlockerMetadata
exists, err := afero.Exists(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
"directory", name, "error", err)
return metadata, false
}
if !exists {
secret.Warn("Skipping unlocker directory with missing metadata file",
"directory", name)
return metadata, false
}
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
if err != nil {
secret.Warn("Skipping unlocker directory with unreadable metadata file",
"directory", name, "error", err)
return metadata, false
}
err = json.Unmarshal(metadataBytes, &metadata)
if err != nil {
secret.Warn("Skipping unlocker directory with corrupt metadata file",
"directory", name, "error", err)
return metadata, false
}
return metadata, true
}
// HasUnlocker reports whether RemoveUnlocker finds something to remove by
// the ID unlockerID: an unlocker with that ID, or an unlocker directory of
// that name that ListUnlockers skips.
func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
vaultDir, err := v.GetDirectory()
if err != nil {
return false, err
}
_, unlockerDir, err := v.findUnlockerByID(
filepath.Join(vaultDir, "unlockers.d"), unlockerID)
if err != nil {
return false, err
}
return unlockerDir != "", nil
}
// RemoveUnlocker removes an unlocker from this vault. An unlocker
// directory that ListUnlockers skips is removed by its directory name; its
// type is unknown, so only the directory is removed.
func (v *Vault) RemoveUnlocker(unlockerID string) error { func (v *Vault) RemoveUnlocker(unlockerID string) error {
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
@@ -282,15 +306,19 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Find the unlocker by ID // Find the unlocker by ID
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID) unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
if err != nil { if err != nil {
return err return err
} }
if unlocker == nil { if unlockerDir == "" {
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound) return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
} }
if unlocker == nil {
return secret.RemoveDirAtomic(v.fs, unlockerDir)
}
// Use the unlocker's Remove method // Use the unlocker's Remove method
return unlocker.Remove() return unlocker.Remove()
} }
@@ -306,12 +334,14 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
// Find the unlocker by ID // Find the unlocker by ID
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID) unlocker, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
if err != nil { if err != nil {
return err return err
} }
if targetUnlockerDir == "" { // A directory found without an unlocker is one ListUnlockers skips; it
// cannot be selected.
if unlocker == nil {
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound) return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
} }
@@ -332,7 +362,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
return nil return nil
} }
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker // CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
// directory of its own, makes it the current unlocker, and only then removes
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
// point leaves a complete current unlocker, the old one or the new.
// The passphrase must be provided as a LockedBuffer for security // The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker( func (v *Vault) CreatePassphraseUnlocker(
passphrase *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
@@ -344,34 +377,55 @@ 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, even when replacing the current unlocker. // get it changes nothing.
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)
} }
// Create unlocker directory unlockersDir := filepath.Join(vaultDir, "unlockers.d")
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms) // The passphrase unlockers the new one replaces
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err) return nil, err
} }
unlocker, err := writePassphraseUnlocker(v.fs, vaultDir, ltIdentity, passphrase)
if err != nil {
return nil, err
}
for _, oldDir := range oldDirs {
err = secret.RemoveDirAtomic(v.fs, oldDir)
if err != nil {
return nil, fmt.Errorf(
"created and selected the new passphrase unlocker: %w", err)
}
}
return unlocker, nil
}
// writePassphraseUnlocker writes a new passphrase unlocker of the long-term
// key ltIdentity into the vault directory vaultDir, in a directory of its own,
// and makes it the vault's current unlocker.
func writePassphraseUnlocker(
fs afero.Fs, vaultDir string, ltIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) {
createdAt := time.Now()
unlockerDir := filepath.Join(vaultDir, "unlockers.d", 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()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to generate unlocker: %w", err) return nil, fmt.Errorf("failed to generate unlocker: %w", err)
} }
// Write the unlocker keypair (public and passphrase-encrypted private)
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
if err != nil {
return nil, err
}
// Encrypt long-term private key to this unlocker // Encrypt long-term private key to this unlocker
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String())) ltPrivKeyBuffer := secret.IdentityToLockedBuffer(ltIdentity)
defer ltPrivKeyBuffer.Destroy() defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
@@ -380,18 +434,9 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err) return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
} }
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
err = secret.WriteFileAtomic(v.fs, ltPrivKeyPath, encryptedLtPrivKey)
if err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Write the metadata last: readers skip an unlocker directory without
// it, so an unlocker interrupted before this point is never used.
metadata := UnlockerMetadata{ metadata := UnlockerMetadata{
Type: unlockerTypePassphrase, Type: unlockerTypePassphrase,
CreatedAt: time.Now(), CreatedAt: createdAt,
Flags: []string{}, Flags: []string{},
} }
@@ -400,23 +445,54 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, fmt.Errorf("failed to marshal metadata: %w", err) return nil, fmt.Errorf("failed to marshal metadata: %w", err)
} }
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json") // Write the unlocker's files, the metadata last
err = secret.WriteDir(fs, unlockerDir, func(dir string) error {
err = secret.WriteFileAtomic(v.fs, metadataPath, metadataBytes) return writeUnlockerFiles(fs, dir, unlockerIdentity, passphrase,
encryptedLtPrivKey, metadataBytes)
})
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err) return nil, err
} }
// Create the unlocker instance // Make the new unlocker the current one
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata) currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
// Select this unlocker as current err = secret.WriteFileAtomic(fs, currentUnlockerPath,
err = v.SelectUnlocker(unlocker.GetID()) []byte(filepath.Base(unlockerDir)))
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)
} }
return unlocker, nil return secret.NewPassphraseUnlocker(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
@@ -450,26 +526,27 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
return metadata, nil return metadata, nil
} }
// writeUnlockerKeypair writes the unlocker's public key and its // writeUnlockerFiles writes the files of a passphrase unlocker into
// passphrase-encrypted private key into the unlocker directory. // unlockerDir: its public key, its passphrase-encrypted private key, the
func (v *Vault) writeUnlockerKeypair( // long-term private key encrypted to it, and its metadata, last.
func writeUnlockerFiles(
fs afero.Fs,
unlockerDir string, unlockerDir string,
unlockerIdentity *age.X25519Identity, unlockerIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
encryptedLtPrivKey, metadataBytes []byte,
) error { ) error {
// Write public key // Write public key
pubKeyPath := filepath.Join(unlockerDir, "pub.age") pubKeyPath := filepath.Join(unlockerDir, "pub.age")
err := secret.WriteFileAtomic(v.fs, pubKeyPath, err := secret.WriteFileAtomic(fs, pubKeyPath,
[]byte(unlockerIdentity.Recipient().String())) []byte(unlockerIdentity.Recipient().String()))
if err != nil { if err != nil {
return fmt.Errorf("failed to write unlocker public key: %w", err) return fmt.Errorf("failed to write unlocker public key: %w", err)
} }
// Encrypt private key with passphrase // Encrypt private key with passphrase
privKeyStr := unlockerIdentity.String() privKeyBuffer := secret.IdentityToLockedBuffer(unlockerIdentity)
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
defer privKeyBuffer.Destroy() defer privKeyBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase) encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
@@ -480,10 +557,22 @@ func (v *Vault) writeUnlockerKeypair(
// Write encrypted private key // Write encrypted private key
privKeyPath := filepath.Join(unlockerDir, "priv.age") privKeyPath := filepath.Join(unlockerDir, "priv.age")
err = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey) err = secret.WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
if err != nil { if err != nil {
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err) return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
} }
err = secret.WriteFileAtomic(fs,
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
err = secret.WriteFileAtomic(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return nil return nil
} }
+62 -9
View File
@@ -1,14 +1,15 @@
package vault package vault
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@@ -18,6 +19,13 @@ type Vault struct {
fs afero.Fs fs afero.Fs
stateDir string stateDir string
longTermKey *age.X25519Identity // In-memory long-term key when unlocked longTermKey *age.X25519Identity // In-memory long-term key when unlocked
// Mnemonic, when not nil, is what the long-term key is derived from
// instead of the current unlocker. The caller destroys it.
Mnemonic *memguard.LockedBuffer
// UnlockPassphrase, when not nil, is given to the current unlocker
// when that is a passphrase unlocker, which otherwise prompts for it.
// The caller destroys it.
UnlockPassphrase *memguard.LockedBuffer
} }
// NewVault creates a new Vault instance // NewVault creates a new Vault instance
@@ -56,6 +64,18 @@ func (v *Vault) ClearLongTermKey() {
v.longTermKey = nil v.longTermKey = nil
} }
// SetMnemonic sets v.Mnemonic, for code that has v only as a
// secret.VaultInterface.
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
v.Mnemonic = mnemonic
}
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
// a secret.VaultInterface.
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
v.UnlockPassphrase = passphrase
}
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it // GetOrDeriveLongTermKey gets the long-term key from memory or derives it
// from available sources // from available sources
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) { func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
@@ -66,9 +86,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name) secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
// Try to derive from environment mnemonic first if v.Mnemonic != nil {
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" { return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
} }
// No mnemonic available, try to use current unlocker // No mnemonic available, try to use current unlocker
@@ -80,7 +99,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
if err != nil { if err != nil {
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name) secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
return nil, fmt.Errorf("failed to get current unlocker: %w", err) return nil, v.withMnemonicAdvice(
fmt.Errorf("failed to get current unlocker: %w", err))
} }
secret.DebugWith("Retrieved current unlocker for vault unlock", secret.DebugWith("Retrieved current unlocker for vault unlock",
@@ -94,7 +114,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
// Other unlockers return their own identity, used to decrypt longterm.age. // Other unlockers return their own identity, used to decrypt longterm.age.
ltIdentity, err := v.unlockLongTermKey(unlocker) ltIdentity, err := v.unlockLongTermKey(unlocker)
if err != nil { if err != nil {
return nil, err return nil, v.withMnemonicAdvice(err)
} }
secret.DebugWith("Successfully obtained long-term identity via unlocker", secret.DebugWith("Successfully obtained long-term identity via unlocker",
@@ -181,9 +201,9 @@ func (v *Vault) NumSecrets() (int, error) {
// deriveLongTermKeyFromMnemonic derives the long-term key from the given // deriveLongTermKeyFromMnemonic derives the long-term key from the given
// mnemonic, verifies it against the vault metadata, and caches it in memory. // mnemonic, verifies it against the vault metadata, and caches it in memory.
func (v *Vault) deriveLongTermKeyFromMnemonic( func (v *Vault) deriveLongTermKeyFromMnemonic(
envMnemonic string, mnemonic string,
) (*age.X25519Identity, error) { ) (*age.X25519Identity, error) {
secret.Debug("Using mnemonic from environment for long-term key derivation", secret.Debug("Using mnemonic for long-term key derivation",
"vault_name", v.Name) "vault_name", v.Name)
// Load vault metadata to get the derivation index // Load vault metadata to get the derivation index
@@ -199,7 +219,7 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
return nil, fmt.Errorf("failed to load vault metadata: %w", err) return nil, fmt.Errorf("failed to load vault metadata: %w", err)
} }
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex) ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil { if err != nil {
secret.Debug("Failed to derive long-term key from mnemonic", secret.Debug("Failed to derive long-term key from mnemonic",
"error", err, "vault_name", v.Name) "error", err, "vault_name", v.Name)
@@ -277,3 +297,36 @@ func (v *Vault) unlockLongTermKey(
return ltIdentity, nil return ltIdentity, nil
} }
// withMnemonicAdvice returns err, a failure to get the long-term key through
// the current unlocker, with advice added: that the mnemonic still opens the
// vault, and how to give it a new unlocker. The advice is added only when the
// vault metadata records the key that the mnemonic derives; a vault created
// without a mnemonic records none, and without its metadata the key cannot
// be derived. It is not added when the passphrase could not be read: the
// unlocker was not tried, and adding one would need a passphrase read the
// same way.
func (v *Vault) withMnemonicAdvice(err error) error {
if errors.Is(err, secret.ErrPassphraseNotRead) {
return err
}
vaultDir, _ := v.GetDirectory()
metadata, metadataErr := LoadVaultMetadata(v.fs, vaultDir)
if metadataErr != nil || metadata.PublicKeyHash == "" {
return err
}
// 'secret unlocker add' acts on the current vault only.
steps := "'secret unlocker add passphrase'"
current, currentErr := GetCurrentVault(v.fs, v.stateDir)
if currentErr != nil || current.Name != v.Name {
steps = fmt.Sprintf("'secret vault select %s', then %s", v.Name, steps)
}
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
"%s with %s set to the mnemonic to give it a new unlocker",
err, v.Name, steps, secret.EnvMnemonic)
}
+46 -10
View File
@@ -2,6 +2,7 @@ package vault_test
import ( import (
"bytes" "bytes"
"errors"
"path/filepath" "path/filepath"
"slices" "slices"
"testing" "testing"
@@ -27,12 +28,19 @@ const (
testPassphrase = "test-passphrase" testPassphrase = "test-passphrase"
) )
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel // testMnemonicBuffer returns testMnemonic in a locked buffer that is
func TestVaultOperations(t *testing.T) { // destroyed when the test ends.
// Test environment will be cleaned up automatically by t.Setenv func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Setenv(secret.EnvMnemonic, testMnemonic) t.Helper()
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
//nolint:paralleltest // order-dependent subtests forbid parallel
func TestVaultOperations(t *testing.T) {
// Use in-memory filesystem // Use in-memory filesystem
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
@@ -64,7 +72,8 @@ func TestVaultOperations(t *testing.T) {
func testCreateVault(t *testing.T, fs afero.Fs) { func testCreateVault(t *testing.T, fs afero.Fs) {
t.Helper() t.Helper()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -221,6 +230,8 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
} }
// Test vault unlocking (should happen automatically via mnemonic) // Test vault unlocking (should happen automatically via mnemonic)
vlt.Mnemonic = testMnemonicBuffer(t)
if vlt.Locked() { if vlt.Locked() {
_, err := vlt.UnlockVault() _, err := vlt.UnlockVault()
if err != nil { if err != nil {
@@ -281,15 +292,14 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
} }
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) { func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Set test environment variables t.Parallel()
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
// Use in-memory filesystem // Use in-memory filesystem
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault // Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName) vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -335,3 +345,29 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
} }
} }
} }
// TestCreateVaultUnlockerNeedsMnemonic checks that CreateVault, given a
// passphrase for an unlocker but no mnemonic to derive the long-term key from,
// fails without writing anything.
func TestCreateVaultUnlockerNeedsMnemonic(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
_, err := vault.CreateVault(fs, testStateDir, testVaultName, nil, passphrase)
if !errors.Is(err, vault.ErrUnlockerWithoutMnemonic) {
t.Fatalf("Expected ErrUnlockerWithoutMnemonic, got %v", err)
}
exists, err := afero.Exists(fs, testStateDir)
if err != nil {
t.Fatalf("Failed to check for the state directory: %v", err)
}
if exists {
t.Errorf("CreateVault wrote the state directory")
}
}
+3 -3
View File
@@ -1,7 +1,6 @@
#!/bin/sh #!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own # script/check: run all checks (test, lint, lint-darwin, fmt-check). Our
# extension to scripts-to-rule-them-all. Must not modify any files. # own extension to scripts-to-rule-them-all. Must not modify any files.
# Generic: usually needs no adaptation.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -9,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() { main() {
"$SCRIPT_DIR/test" "$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint" "$SCRIPT_DIR/lint"
"$SCRIPT_DIR/lint-darwin"
"$SCRIPT_DIR/fmt-check" "$SCRIPT_DIR/fmt-check"
} }
+5 -1
View File
@@ -4,13 +4,17 @@
# The Gitea workflow runs this on push. The memlock ulimit lets the tests # The Gitea workflow runs this on push. The memlock ulimit lets the tests
# that lock large secrets in memory (memguard mlocks them) run; under the # that lock large secrets in memory (memguard mlocks them) run; under the
# lower limit of a plain `docker build .` they are skipped. # lower limit of a plain `docker build .` they are skipped.
# A cached build checks nothing: a new CHECK_EPOCH on every run makes the
# Dockerfile's check steps run again on an unchanged tree, while its base
# images and module downloads stay cached.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --ulimit memlock=-1:-1 . docker build --ulimit memlock=-1:-1 \
--build-arg CHECK_EPOCH="$(date +%s)" .
} }
main "$@" main "$@"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# script/lint-darwin: type-check (go vet) and lint the code as a macOS
# build compiles it, from Linux, in docker only. CI runs on Linux, which
# never compiles the files built only for macOS. Builds the lint-darwin
# stage of Dockerfile.lint, rebuilt on every run as script/lint does.
#
# Cgo is off: compiling cgo code for macOS needs Apple's SDK headers. That
# leaves out the files built only with cgo on macOS: the keychain unlocker's
# calls into the keychain (keychainunlocker_cgo.go, and
# keychainunlocker_test.go) and the Secure Enclave bindings (internal/macse).
# Nothing on Linux checks those.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build \
--progress=plain \
--target lint-darwin \
--no-cache-filter=lint-darwin \
--output=type=cacheonly \
-f Dockerfile.lint .
}
main "$@"