1 Commits
Author SHA1 Message Date
sneak abde576300 Type-check and lint the macOS build from Linux (closes #50)
check / check (push) Failing after 4s
script/lint-darwin (make lint-darwin; run by script/check, and its
commands by the Dockerfile lint stage) runs go vet and golangci-lint
with GOOS=darwin and cgo off. Compiling cgo for macOS needs Apple's SDK,
so the three functions that call go-keychain, which is cgo there, move
to keychainunlocker_cgo.go; a macOS build without cgo gets
keychainunlocker_nocgo.go and the macse stub, whose errors name the
missing macOS build with cgo. The rest of the keychain unlocker and its
plain-Go tests are now checked; their findings are fixed without
changing behaviour, and lines over 88 columns in the unchecked files
are wrapped.

Model: opus-5-5
2026-10-04 15:15:59 +00:00
100 changed files with 2367 additions and 4407 deletions
+98 -104
View File
@@ -4,160 +4,154 @@ 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 direction I * Come in hot with strong opinions, even if they are contrary to the
am headed. direction I am headed.
- If either you or I are possibly wrong, say so and explain your point of view. * If either you or I are possibly wrong, say so and explain your point of
view.
- Point out great alternatives I haven't thought of, even when I'm not asking * Point out great alternatives I haven't thought of, even when I'm not
for them. asking 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 data * I want excellence, so always be on the lookout for divergences from good
model design or best practices for object oriented development. data 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 for * Please read and understand the `README.md` file in the root of the repo
project-specific contextual information, including development policies, for project-specific contextual information, including development
practices, and current implementation status. policies, 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 than `golangci-lint run`. When you think your changes are complete, rather
making three different tool calls to check, you can just run than making three different tool calls to check, you can just run `make
`make test && make fmt && make lint` as a single tool call which will save 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 a 2. Always write a `Makefile` with the default target being `test`, and with
`fmt` target that formats the code. The `test` target should run all tests in a `fmt` target that formats the code. The `test` target should run all
the project, and the `fmt` target should format the code. `test` should also tests in the project, and the `fmt` target should format the code.
have a prerequisite target `lint` that should run any linters that are `test` should also have a prerequisite target `lint` that should run any
configured for the project. linters that are configured for the project.
3. After each completed bugfix or feature, the code must be committed. Do all of 3. After each completed bugfix or feature, the code must be committed. Do
the pre-commit checks (test, lint, fmt) before committing, of course. all of the pre-commit checks (test, lint, fmt) before committing, of
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 is a tests, or change the tests specifically to make them pass (unless there
bug in the test). This is cheating, and it is bad. You should only be is a bug in the test). This is cheating, and it is bad. You should only
modifying the test if it is incorrect or if the test is no longer relevant. be modifying the test if it is incorrect or if the test is no longer
In almost all cases, you should be fixing the code that is being tested, or relevant. In almost all cases, you should be fixing the code that is
updating the tests to match a refactored implementation. being tested, or 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 to see store UTC. Set the local timezone to UTC on startup. If the user needs
the time in a different timezone, store the user's timezone in a separate to see the time in a different timezone, store the user's timezone in a
field and convert the UTC time to the user's timezone when displaying it. For separate field and convert the UTC time to the user's timezone when
internal use and internal applications and administrative purposes, always displaying it. For internal use and internal applications and
display UTC. administrative purposes, always 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 feature, correct syntax (ability to compile/import). If you are writing a new
write a test for it. You don't need to target complete coverage, but you feature, write a test for it. You don't need to target complete
should at least test any new functionality you add. If you are fixing a bug, coverage, but you should at least test any new functionality you add. If
write a test first that reproduces the bug, and then fix the bug in the code. you are fixing a bug, write a test first that reproduces the bug, and
then fix the bug in the code.
8. When implementing new features, be aware of potential side-effects (such as 8. When implementing new features, be aware of potential side-effects (such
state files on disk, data in the database, etc.) and ensure that it is as 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 the messages (but do not log secrets). If stdout is not a terminal, output
structured logs in jsonl format. the 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 of the anywhere more than once. Always define a constant (usually at the top
file) and give it a descriptive name, then use that constant in the code of the file) and give it a descriptive name, then use that constant in
instead of the bare string or number. the code 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 of the Making the changes and committing them is sufficient. If anything out
ordinary happened, please explain it, but in the normal case where you found of the ordinary happened, please explain it, but in the normal case
and fixed the bug, or implemented the feature, there is no need for the where you found and fixed the bug, or implemented the feature, there is
end-of-change summary. no need for the end-of-change summary.
12. Do not create additional files in the root directory of the project without 12. Do not create additional files in the root directory of the project
asking permission first. Configuration files, documentation, and build files without asking permission first. Configuration files, documentation, and
are acceptable in the root, but source code and other files should be build files are acceptable in the root, but source code and other files
organized in appropriate subdirectories. should be organized in appropriate subdirectories.
## Python-Specific Guidelines ## Python-Specific Guidelines
1. **Type Annotations (UP006)**: Use built-in collection types directly for type 1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error.
annotations instead of importing from `typing`. This avoids the UP006 linter
error. **Good (modern Python 3.9+):**
```python
**Good (modern Python 3.9+):** def process_items(items: list[str]) -> dict[str, int]:
counts: dict[str, int] = {}
```python return counts
def process_items(items: list[str]) -> dict[str, int]: ```
counts: dict[str, int] = {}
return counts **Avoid (triggers UP006):**
``` ```python
from typing import List, Dict
**Avoid (triggers UP006):**
def process_items(items: List[str]) -> Dict[str, int]:
```python counts: Dict[str, int] = {}
from typing import List, Dict return counts
```
def process_items(items: List[str]) -> Dict[str, int]:
counts: Dict[str, int] = {} For optional types, use the `|` operator instead of `Union`:
return counts ```python
``` # Good
def get_value(key: str) -> str | None:
For optional types, use the `|` operator instead of `Union`: return None
```python # Avoid
# Good from typing import Optional, Union
def get_value(key: str) -> str | None: def get_value(key: str) -> Optional[str]:
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 1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
errors via return values.
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle 2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
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 4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
the code.
5. **All PRs must pass `make check` with zero failures.** No exceptions, no 5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
"pre-existing issue" excuses.
6. **Pin external dependencies by commit hash**, not mutable tags. 6. **Pin external dependencies by commit hash**, not mutable tags.
+73 -71
View File
@@ -1,93 +1,95 @@
# IMPORTANT RULES # IMPORTANT RULES
- Claude is an inanimate tool. The spam that Claude attempts to insert into * Claude is an inanimate tool. The spam that Claude attempts to insert into
commit messages (which it erroneously refers to as "attribution") is not commit messages (which it erroneously refers to as "attribution") is not
attribution, as I am the sole author of code created using Claude. It is attribution, as I am the sole author of code created using Claude. It is
corporate advertising for Anthropic and is therefore completely unacceptable corporate advertising for Anthropic and is therefore completely
in commit messages. unacceptable in commit messages.
- Tests should always be run before committing code. No commits should be made * Tests should always be run before committing code. No commits should be
that do not pass tests. made that do not pass tests.
- Code should always be formatted before committing. Do not commit unformatted * Code should always be formatted before committing. Do not commit
code. unformatted code.
- Code should always be linted and linter errors fixed before committing. NEVER * Code should always be linted and linter errors fixed before committing.
commit code that does not pass the linter. DO NOT modify the linter config NEVER commit code that does not pass the linter. DO NOT modify the linter
unless specifically instructed. config unless specifically instructed.
- The test suite is fast and local. When running tests, NEVER run individual * The test suite is fast and local. When running tests, NEVER run
parts of the test suite, always run the whole thing by running "make test". individual parts of the test suite, always run the whole thing by running
"make test".
- Do not stop working on a task until you have reached the definition of done * Do not stop working on a task until you have reached the definition of
provided to you in the initial instruction. Don't do part or most of the work, done provided to you in the initial instruction. Don't do part or most of
do all of the work until the criteria for done are met. the work, do all of the work until the criteria for done are met.
- When you complete each task, if the tests are passing and the code is * When you complete each task, if the tests are passing and the code is
formatted and there are no linter errors, always commit and push your work. formatted and there are no linter errors, always commit and push your
Use a good commit message and don't mention any author or co-author work. Use a good commit message and don't mention any author or co-author
attribution. attribution.
- Do not create additional files in the root directory of the project without * Do not create additional files in the root directory of the project
asking permission first. Configuration files, documentation, and build files without asking permission first. Configuration files, documentation, and
are acceptable in the root, but source code and other files should be build files are acceptable in the root, but source code and other files
organized in appropriate subdirectories. should be organized in appropriate subdirectories.
- Do not use bare strings or numbers in code, especially if they appear anywhere * Do not use bare strings or numbers in code, especially if they appear
more than once. Always define a constant (usually at the top of the file) and anywhere more than once. Always define a constant (usually at the top of
give it a descriptive name, then use that constant in the code instead of the the file) and give it a descriptive name, then use that constant in the
bare string or number. code instead of the bare string or number.
- If you are fixing a bug, write a test first that reproduces the bug and fails, * If you are fixing a bug, write a test first that reproduces the bug and
and then fix the bug in the code, using the test to verify that the fix fails, and then fix the bug in the code, using the test to verify that the
worked. fix worked.
- When implementing new features, be aware of potential side-effects (such as * When implementing new features, be aware of potential side-effects (such
state files on disk, data in the database, etc.) and ensure that it is as state files on disk, data in the database, etc.) and ensure that it is
possible to mock or stub these side-effects in tests when designing an API. possible to mock or stub these side-effects in tests when designing an
API.
- When dealing with dates and times or timestamps, always use, display, and * 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 to see store UTC. Set the local timezone to UTC on startup. If the user needs
the time in a different timezone, store the user's timezone in a separate to see the time in a different timezone, store the user's timezone in a
field and convert the UTC time to the user's timezone when displaying it. For separate field and convert the UTC time to the user's timezone when
internal use and internal applications and administrative purposes, always displaying it. For internal use and internal applications and
display UTC. administrative purposes, always display UTC.
- When implementing programs, put the main.go in ./cmd/<program_name>/main.go * When implementing programs, put the main.go in
and put the program's code in ./internal/<program_name>/. This allows for ./cmd/<program_name>/main.go and put the program's code in
multiple programs to be implemented in the same repository without cluttering ./internal/<program_name>/. This allows for multiple programs to be
the root directory. main.go should simply import and call implemented in the same repository without cluttering the root directory.
<program_name>.CLIEntry(). The full implementation should be in main.go should simply import and call <program_name>.CLIEntry(). The
./internal/<program_name>/. full implementation should be in ./internal/<program_name>/.
- When you are instructed to make the tests pass, DO NOT delete tests, skip * 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 is a tests, or change the tests specifically to make them pass (unless there
bug in the test). This is cheating, and it is bad. You should only be is a bug in the test). This is cheating, and it is bad. You should only
modifying the test if it is incorrect or if the test is no longer relevant. In be modifying the test if it is incorrect or if the test is no longer
almost all cases, you should be fixing the code that is being tested, or relevant. In almost all cases, you should be fixing the code that is
updating the tests to match a refactored implementation. being tested, or updating the tests to match a refactored implementation.
- Always write a `Makefile` with the default target being `test`, and with a * Always write a `Makefile` with the default target being `test`, and with a
`fmt` target that formats the code. The `test` target should run all tests in `fmt` target that formats the code. The `test` target should run all
the project, and the `fmt` target should format the code. `test` should also tests in the project, and the `fmt` target should format the code. `test`
have a prerequisite target `lint` that should run any linters that are should also have a prerequisite target `lint` that should run any linters
configured for the project. that are configured for the project.
- After each completed bugfix or feature, the code must be committed. Do all of * After each completed bugfix or feature, the code must be committed. Do
the pre-commit checks (test, lint, fmt) before committing, of course. After all of the pre-commit checks (test, lint, fmt) before committing, of
each commit, push to the remote. course. After each commit, push to the remote.
- Always write tests, even if they are extremely simple and just check for * 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 feature, correct syntax (ability to compile/import). If you are writing a new
write a test for it. You don't need to target complete coverage, but you feature, write a test for it. You don't need to target complete coverage,
should at least test any new functionality you add. but you should at least test any new functionality you add.
- Always use structured logging. Log any relevant state/context with the * Always use structured logging. Log any relevant state/context with the
messages (but do not log secrets). If stdout is not a terminal, output the messages (but do not log secrets). If stdout is not a terminal, output
structured logs in jsonl format. Use go's log/slog. the structured logs in jsonl format. Use go's log/slog.
- You do not need to summarize your changes in the chat after making them. * 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 of the Making the changes and committing them is sufficient. If anything out of
ordinary happened, please explain it, but in the normal case where you found the ordinary happened, please explain it, but in the normal case where you
and fixed the bug, or implemented the feature, there is no need for the found and fixed the bug, or implemented the feature, there is no need for
end-of-change summary. the end-of-change summary.
+2 -14
View File
@@ -1,22 +1,10 @@
# node and yarn, copied into the lint stage for prettier, which checks the
# markdown formatting: node of the version script/bootstrap pins, built on
# Debian as the lint stage's image is.
# node:22.17.0-bookworm-slim, 2025-07-08
FROM node@sha256:b04ce4ae4e95b522112c2e5c52f781471a5cbc3b594527bcddedee9bc48c03a0 AS node
# Lint stage — fast feedback on formatting and lint issues # Lint stage — fast feedback on formatting and lint issues
# 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 lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /opt/yarn-v1.22.22 /opt/yarn-v1.22.22
ENV PATH="/opt/yarn-v1.22.22/bin:${PATH}"
# script/bootstrap downloads the Go modules and installs prettier
WORKDIR /src WORKDIR /src
COPY script/ script/ COPY go.mod go.sum ./
COPY go.mod go.sum package.json yarn.lock ./ RUN go mod download
RUN script/bootstrap
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps # 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 # below run again on each build, an unchanged tree included, while the
+169 -251
View File
@@ -1,90 +1,75 @@
# secret - Local Secret Manager # secret - Local Secret Manager
## Description secret is a command-line local secret manager that implements a hierarchical
key architecture for storing and managing sensitive data. It supports
multiple vaults, various unlock mechanisms, and provides secure storage
using the `age` encryption library.
`secret` is a WTFPL-licensed Go command-line local secret manager by It could be used as password manager, but was not designed as such. I
[@sneak](https://sneak.berlin) that implements a hierarchical key architecture created it to scratch an itch for a secure key/value store for replacing a
for storing and managing sensitive data. It supports multiple vaults, various bunch of pgp-encrypted files in a directory structure.
unlock mechanisms, and provides secure storage using the `age` encryption
library.
## Getting Started ## Core Architecture
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 the 1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide
foundation for all encryption the foundation for all encryption
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting 2. **Unlockers**: Short-term keys that encrypt the long-term keys,
multiple authentication methods supporting multiple authentication methods
3. **Version-specific Keys**: Per-version keys that encrypt individual secret 3. **Version-specific Keys**: Per-version keys that encrypt individual
values secret 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 including creation time and validity period, encrypted to the - Metadata (unencrypted) including creation time and validity period
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 key Vaults provide logical separation of secrets, each with its own long-term
and unlocker set. This allows for complete isolation between different contexts key and unlocker set. This allows for complete isolation between different
(work, personal, projects). contexts (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`
@@ -93,7 +78,6 @@ 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
@@ -116,13 +100,13 @@ 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. It first asks for **DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`,
confirmation, naming the vault and how many secrets it holds (see this command does not ask for confirmation.
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
cannot be removed. Removing the current vault makes another vault the current
one.
- `--force, -f`: Remove without asking, also a vault that contains secrets Requires --force if the vault contains secrets. With --force, will
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
@@ -130,66 +114,54 @@ 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 `/` are **Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/`
allowed, and a name must not be empty, start with `.` or `/`, end with `/`, are 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 matching. Lists all secrets in the current vault. Optional filter for substring
matching.
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑 #### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for **DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
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 - **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
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` for - Fails if the destination is the source under another name, such as `foo`
`Foo` on a case-insensitive filesystem (the macOS default); there, to change for `Foo` on a case-insensitive filesystem (the macOS default); there, to
only the case of a name, move the secret to a third name first change 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 Lists all versions of a secret showing creation time, status, and validity period.
period.
#### `secret version promote <secret-name> <version>` #### `secret version promote <secret-name> <version>`
Promotes a specific version to current by rewriting the secret's `current` file Promotes a specific version to current by updating the symlink. Does not
to name it. Does not modify any timestamps, allowing for rollback scenarios. modify any timestamps, allowing for rollback scenarios.
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑 #### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑
**DANGER**: Permanently removes a specific version of a secret. It first asks **DANGER**: Permanently removes a specific version of a secret. Like Unix
for confirmation, naming the version, the secret and its vault (see `rm`, this command does not ask for confirmation.
[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)
@@ -202,7 +174,6 @@ 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
@@ -211,40 +182,32 @@ 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. An unlocker's ID, Lists all unlockers in the current vault with their metadata.
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 A vault has one passphrase unlocker: adding one replaces the one the vault
specified) has, which is removed only once the new one is the current unlocker.
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. It first asks for confirmation, **DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
naming the unlocker and its vault and saying whether it is the vault's last does not ask for confirmation. Cannot remove the last unlocker if the vault
unlocker; for the last one it says how many secrets the vault holds and warns has secrets unless --force is used. An unlocker directory that
that the vault then opens only with its mnemonic (see `secret unlocker list` skips with a warning, because its metadata cannot be
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker read or parsed, is removed by the directory name the warning gives.
directory that `secret unlocker list` skips with a warning, because its metadata - `--force, -f`: Force removal of last unlocker even if vault has secrets
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
@@ -258,8 +221,7 @@ 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 Imports a secret from a file and stores it in the current vault under the given name.
name.
#### `secret vault import [vault-name]` #### `secret vault import [vault-name]`
@@ -269,8 +231,7 @@ 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, Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
generates a new Age key.
#### `secret decrypt <secret-name> [--input=file] [--output=file]` #### `secret decrypt <secret-name> [--input=file] [--output=file]`
@@ -280,13 +241,8 @@ 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:
``` ```
~/.config/berlin.sneak.pkg.secret/ ~/.local/share/secret/
├── vaults.d/ ├── vaults.d/
│ ├── default/ │ ├── default/
│ │ ├── unlockers.d/ │ │ ├── unlockers.d/
@@ -299,12 +255,12 @@ the state directory instead. On Linux:
│ │ │ │ │ │ ├── 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.age # Encrypted metadata │ │ │ │ │ │ └── metadata.json # Unencrypted metadata
│ │ │ │ │ └── 20231216.001/ # Another version │ │ │ │ │ └── 20231216.001/ # Another version
│ │ │ │ └── current # Current version's name: 20231216.001 │ │ │ │ └── current -> versions/20231216.001
│ │ │ └── database%password/ # Secret: database/password │ │ │ └── database%password/ # Secret: database/password
│ │ │ ├── versions/ │ │ │ ├── versions/
│ │ │ └── current # Current version's name: 20231215.001 │ │ │ └── current -> versions/20231215.001
│ │ ├── vault-metadata.json # Vault metadata │ │ ├── vault-metadata.json # Vault metadata
│ │ ├── pub.age # Long-term public key │ │ ├── pub.age # Long-term public key
│ │ └── current-unlocker # Current unlocker's directory name │ │ └── current-unlocker # Current unlocker's directory name
@@ -314,61 +270,49 @@ the state directory instead. On Linux:
│ ├── vault-metadata.json │ ├── vault-metadata.json
│ ├── pub.age │ ├── pub.age
│ └── current-unlocker │ └── current-unlocker
├── currentvault # Current vault's name: default └── currentvault -> vaults.d/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 - **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
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
- Kept on this Mac only: the keychain item is never synced to other devices - Protected by system authentication (Touch ID, password)
- 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 - Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
Program required) - ECIES encryption: vault long-term key encrypted directly by SE hardware
- ECIES encryption: the vault long-term key is encrypted directly to the SE - Protected by biometric authentication (Touch ID) or system password
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 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.
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
- A version's private key decrypts only that version's value and metadata - Provides forward secrecy and granular access control
### Environment Variables ### Environment Variables
@@ -382,19 +326,18 @@ 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 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 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 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 each one as soon as it has read it, so that the programs it runs itself, such
`gpg`, do not inherit it, but that erases nothing: the environment the process as `gpg`, do not inherit it, but that erases nothing: the environment the
started with, and its memory, still hold the value. The interactive prompt, process started with, and its memory, still hold the value. The interactive
which every command except `secret vault import` offers when the variable is not prompt, which every command except `secret vault import` offers when the
set, is the safer default; `secret vault import` has no prompt and needs both variable is not set, is the safer default; `secret vault import` has no prompt
variables. and needs both variables.
## Security Features ## Security Features
### Encryption ### Encryption
- Uses the [age encryption library](https://age-encryption.org/) with X25519 - Uses the [age encryption library](https://age-encryption.org/) with X25519 keys
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
@@ -413,8 +356,7 @@ variables.
- 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 - Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
`sc_auth` / CryptoTokenKit)
## Examples ## Examples
@@ -435,7 +377,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 ⚠️ 🛑 (asks first - PERMANENT!) # Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret remove ssh/servers/web01 secret remove ssh/servers/web01
``` ```
@@ -458,12 +400,11 @@ echo "personal-email-pass" | secret add email/password
# List all vaults # List all vaults
secret vault list secret vault list
# Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!) # Remove a vault ⚠️ 🛑 (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
@@ -477,7 +418,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 ⚠️ 🛑 (asks first!) # Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
secret unlocker remove <unlocker-id> secret unlocker remove <unlocker-id>
``` ```
@@ -490,7 +431,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 ⚠️ 🛑 (asks first - PERMANENT!) # Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
secret version remove database/prod/password 20231214.001 secret version remove database/prod/password 20231214.001
``` ```
@@ -510,31 +451,21 @@ 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)
- **age Files**: Standard age encryption format (.age extension), except - **Metadata**: Unencrypted JSON format with timestamps and type information
`pub.age`, which holds an age public key as text - **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
- **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 - **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair
mnemonic, and thus a unique key pair - **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same - **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
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
@@ -570,7 +501,6 @@ to add or use them.
## Development ## Development
### Building ### Building
```bash ```bash
make build # Build binary make build # Build binary
make test # Run tests make test # Run tests
@@ -578,11 +508,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
@@ -590,73 +520,61 @@ make test # Run all 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 them. We development workflow, and the Makefile targets are thin shims that call
provide: them. We provide:
- `script/bootstrap` — install all dependencies (Go, Go module download, and - `script/bootstrap` — install all dependencies (Go, Go module
node, yarn and prettier for formatting markdown), idempotently; prettier is download), idempotently; golangci-lint is not installed, it runs in
pinned by hash in `package.json` and `yarn.lock`; golangci-lint is not docker
installed, it runs in 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 other - `script/projectname` — output the project name (`secret`); used by
scripts such as `script/docker` other scripts such as `script/docker`
- `script/build` — build the `secret` binary into the repo root, stamping the - `script/build` — build the `secret` binary into the repo root, stamping
version (`VERSION` from the environment, else `git describe`) and the git the version (`VERSION` from the environment, else `git describe`) and
commit the git commit
- `script/test` — run `go vet` and the test suite (verbose rerun on failure) - `script/test` — run `go vet` and the test suite (verbose rerun on
- `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`, failure)
where the linter is a build step that runs on every call, also on an unchanged - `script/lint` — run `golangci-lint` in docker only: builds
tree `Dockerfile.lint`, where the linter is a build step that runs on every
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code call, also on an unchanged tree
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never - `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on
compiles; cgo is off, so the keychain unlocker's calls into the keychain the code as a macOS build compiles it (`GOOS=darwin`), which a Linux
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`) build never compiles; cgo is off, so the keychain unlocker's calls into
and the Secure Enclave bindings (`internal/macse`) are not checked the keychain (`internal/secret/keychainunlocker_cgo.go`, and
- `script/fmt` — format all Go code with `go fmt` and every markdown file with `keychainunlocker_test.go`) and the Secure Enclave bindings
prettier (4-space tabs, `proseWrap: always`) (writes) (`internal/macse`) are not checked
- `script/fmt-check` — check the same formatting without writing; the - `script/fmt` — format all Go code (writes)
`Dockerfile` lint stage runs it, so an unformatted Go or markdown file fails - `script/fmt-check` — check formatting without writing
the build - `script/check` — run `script/test`, `script/lint`,
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and `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 memlock=-1:-1 .` - `script/cibuild` — CI entrypoint: `docker build --ulimit
(memguard needs mlock; the Dockerfile runs the checks), with a new memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
`CHECK_EPOCH` build argument on every run so the checks run again on an checks), with a new `CHECK_EPOCH` build argument on every run so the
unchanged tree checks run again on an unchanged tree
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then - `script/precommit` — pre-commit checks: `go mod tidy` verification,
`script/check` then `script/check`
- `script/install-precommit` — install the git pre-commit hook that runs - `script/install-precommit` — install the git pre-commit hook that
`script/precommit` runs `script/precommit`
## Features ## Features
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, - **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers
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
## TODO # Author
Open work is tracked on the Made with love and lots of expensive SOTA AI by
[issue tracker](https://git.eeqj.de/sneak/secret/issues), which is [sneak](https://sneak.berlin) in Berlin in the summer of 2025.
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.
## License Released as a free software gift to the world, no strings attached, under
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)
+309 -439
View File
@@ -1,477 +1,347 @@
# Workflow # Workflow
- branch from `next` * branch (from `main`)
- do the Next Step: the next open issue in the `1.0.0` milestone * do the work in Next Step
- log it at the top of Completed Steps * move Next Step to the top of Completed Steps
- commit (`TODO.md` changes in the same commit as the work) * move the top item of Future Steps into Next Step
- push, and open a PR against `next` * commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status # Status
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in
authoritative. flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06).
# Next Step # Next Step
Take the next open issue in the `1.0.0` milestone: Bring the repo into policy compliance in one commit:
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-05: The Go module path is `sneak.berlin/go/secret`, as
`REPO_POLICIES.md` requires, not `git.eeqj.de/sneak/secret`
(https://git.eeqj.de/sneak/secret/issues/43). Every import uses it, as do the
`-X` flags in `script/build` that stamp the version and commit shown by
`secret info`, and the examples in `pkg/agehd/README.md` and
`pkg/bip85/README.md`. `go mod tidy` now lists `github.com/dustin/go-humanize`
and `github.com/fatih/color`, which `internal/cli` imports, as direct
requirements. Code that imported the old path must switch to the new one.
- 2026-10-04: `make fmt` formats every markdown file with prettier (4-space
tabs, `proseWrap: always`) as well as the Go code, and `make fmt-check` checks
both, as the model scripts in the `prompts` repo do
(https://git.eeqj.de/sneak/secret/issues/110). Prettier is pinned by hash in
`package.json` and `yarn.lock`, and `script/bootstrap` installs node, yarn and
prettier. The `Dockerfile` lint stage copies node and yarn from a node image
pinned by hash and runs `script/bootstrap`, so its `make fmt-check` fails the
build on an unformatted markdown file. Every markdown file was formatted once,
wording unchanged.
- 2026-10-04: A mnemonic that cannot be read, in `secret init` and
`secret vault create`, gives an error that names the mnemonic only
(https://git.eeqj.de/sneak/secret/issues/115). It is read with
`secret.ReadMnemonic`, whose every error wraps the new
`secret.ErrMnemonicNotRead`; before, it was read with `ReadPassphrase`, so the
message said "failed to read mnemonic: failed to read passphrase:" and advised
setting `SB_UNLOCK_PASSPHRASE`. Without a terminal it now says "failed to read
mnemonic: stdin is not a terminal (piped input or script). Please set the
SB_SECRET_MNEMONIC environment variable or run interactively". The passphrase
messages no longer repeat "cannot read passphrase" after "failed to read
passphrase:", and empty input gives "nothing was entered".
- 2026-10-04: A failure returns the same error value whichever command hits it
(https://git.eeqj.de/sneak/secret/issues/113). `internal/cli` no longer keeps
its own copies of `vault.ErrSecretNotFound`, `ErrVaultNotFound`,
`ErrVersionNotFound` and `ErrSecretExists`: `secret mv`, `rm`, `decrypt`,
`vault import`, `vault remove` and `version list`, `promote` and `rm` wrap the
`vault` errors. `errUnsupportedUnlockerType` is removed: `secret unlocker add`
gives `errInvalidUnlockerType` for an unknown type, whichever check rejects
it. Off macOS, adding a keychain or Secure Enclave unlocker returns the
`secret` package's error for it, not an `internal/cli` copy; on macOS, the
check that the system is macOS is gone, as it could never fail.
`secret vault import` gives `errInvalidMnemonicPhrase` for an invalid
mnemonic, as `init` and `vault create` do. `secret generate secret` gives
`errLengthTooSmall` for a length below 1 wherever it is checked, and
`errUnsupportedSecretType` for `--type mnemonic` too. `secret import` of a
file over 100MB wraps `errSecretTooLarge`, as `secret add` returns it.
`vault.ErrNilValueBuffer` is replaced by `secret.ErrNilValueBuffer`, which
`secret` already returned under another name. Messages are unchanged, except
that `secret decrypt` of a missing secret says "not found", as `secret get`
does, not "does not exist"; `vault import` of an invalid mnemonic says
"invalid BIP39 mnemonic phrase"; `--type mnemonic` says "unsupported type:
mnemonic (use 'secret generate mnemonic' instead)"; and a file too large to
import says
`failed to read secret from file <path>: secret too large: exceeds 100MB limit`.
Every error of `secret.ReadPassphrase` wraps `secret.ErrPassphraseNotRead`,
which supplies the words "failed to read passphrase" that its callers used to
add themselves; so two passphrases that differ now give only "passphrases do
not match", the words now follow "failed to read mnemonic:" and "failed to
read passphrase confirmation:", and a terminal read error no longer repeats
them. A GPG key the keyring does not hold gives `secret.ErrGPGKeyNotFound`,
found by gpg's status line for "No public key"; before, the message repeated
"failed to resolve GPG key fingerprint" and ended in gpg's exit status. The
keychain unlocker returns `errNilDataBuffer` for nil data; this and its test
build only on macOS with cgo and were only read. `bip85.ErrPasswordTooShort`
and `ErrEncodedTooShort` are removed with their checks: 64 bytes of entropy
always give 86 Base64 or 80 Base85 characters, the most a password length may
ask for. Tests that matched these errors' text use `errors.Is`.
- 2026-10-04: Tests check which error a failure returns with `errors.Is`, not by
matching words of its message (https://git.eeqj.de/sneak/secret/issues/49).
Every exported error that can be returned has a test that the function returns
it, and errors wrapping a cause are checked through the wrapping. Checks that
still match text, because the error has no exported value the test can name,
are listed on the issue.
- 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 - 2026-10-04: `script/lint-darwin` (`make lint-darwin`) runs `go vet` and
`golangci-lint` in docker on the code as a macOS build compiles it `golangci-lint` in docker on the code as a macOS build compiles it
(`GOOS=darwin`), with cgo off (https://git.eeqj.de/sneak/secret/issues/50). (`GOOS=darwin`), with cgo off
`script/check` runs it, and the `Dockerfile` lint stage runs its commands, so (https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
`script/cibuild` does too. Before, CI on Linux never compiled the files built the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
only for macOS. Compiling cgo code for macOS needs Apple's SDK headers, and Before, CI on Linux never compiled the files built only for macOS. Compiling
both `internal/macse` and `github.com/keybase/go-keychain` are cgo on macOS. cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
So the three functions that call `go-keychain` moved from `github.com/keybase/go-keychain` are cgo on macOS. So the three functions
`keychainunlocker.go` to `keychainunlocker_cgo.go`, built only with cgo on that call `go-keychain` moved from `keychainunlocker.go` to
macOS like `macse_darwin.go`. A macOS build without cgo, which before did not `keychainunlocker_cgo.go`, built only with cgo on macOS like
compile, gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose `macse_darwin.go`. A macOS build without cgo, which before did not compile,
errors say the keychain or Secure Enclave needs a macOS build with cgo. The gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
check covers the rest of the keychain unlocker, the Secure Enclave unlocker say the keychain or Secure Enclave needs a macOS build with cgo. The check
and the macOS-only tests other than `keychainunlocker_test.go`, whose lint 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 findings are fixed. For the length and complexity limits, parts of
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved into `GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
functions of their own, and the Secure Enclave unlocker derives the long-term into functions of their own, and the Secure Enclave unlocker derives the
key from the mnemonic through the same function as the keychain unlocker long-term key from the mnemonic through the same function as the keychain
instead of a copy of it. Lines over 88 columns in the files the check cannot unlocker instead of a copy of it. Lines over 88 columns in the files the
see are wrapped. 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 - 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
current unlocker that cannot open the vault current unlocker that cannot open the vault
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a (https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
directory of its own, named with the time to the nanosecond: directory of its own, named with the time to the nanosecond:
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure Enclave `passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
unlocker the keychain item or Secure Enclave key, which names the directory, Enclave unlocker the keychain item or Secure Enclave key, which names the
carries the time instead of the day. `secret.WriteDir` fails on a directory directory, carries the time instead of the day. `secret.WriteDir` fails on a
that exists instead of writing into it. `unlocker add passphrase` writes the directory that exists instead of writing into it. `unlocker add passphrase`
new unlocker, makes it current, and only then removes the vault's other writes the new unlocker, makes it current, and only then removes the vault's
passphrase unlockers; a crash between the last two steps leaves the old one other passphrase unlockers; a crash between the last two steps leaves the old
beside the new, and the old passphrase still opens the vault through it until one beside the new, and the old passphrase still opens the vault through it
the next `unlocker add passphrase` or an `unlocker remove` removes it. A PGP, until the next `unlocker add passphrase` or an `unlocker remove` removes it.
keychain or Secure Enclave unlocker added on the same host and day as another A PGP, keychain or Secure Enclave unlocker added on the same host and day as
of its type is added beside it instead of replacing it. 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 - 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
command, in its `RunE`, into locked buffers on the CLI `Instance`, and unset per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
at once, so that no program the command runs, `gpg` included, inherits them unset at once, so that no program the command runs, `gpg` included,
(https://git.eeqj.de/sneak/secret/issues/60). Nothing below the command reads inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
the environment; the buffers are passed down: `vault.CreateVault` takes the the command reads the environment; the buffers are passed down:
mnemonic (nil for none), a `Vault` derives its long-term key from its `vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
`Mnemonic` and gives its `UnlockPassphrase` to a passphrase unlocker, and the long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
PGP, keychain and Secure Enclave unlocker constructors take both. passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
`CreatePGPUnlocker` sets both on the vault it loads, through `SetMnemonic` and constructors take both. `CreatePGPUnlocker` sets both on the vault it
`SetUnlockPassphrase`, now part of `VaultInterface`, before calling its loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
`GetOrDeriveLongTermKey`. `init` and `vault create` no longer put the mnemonic `VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
into the environment. Unsetting erases nothing: the starting environment `vault create` no longer put the mnemonic into the environment. Unsetting
(`/proc/<pid>/environ`) and memory still hold the value. The README warns erases nothing: the starting environment (`/proc/<pid>/environ`) and
against both variables. memory still hold the value. The README warns against both variables.
- 2026-10-04: `.golangci.yml` is again the canonical file from `sneak/prompts`, - 2026-10-04: `.golangci.yml` is again the canonical file from
byte for byte (https://git.eeqj.de/sneak/secret/issues/66). It runs `sneak/prompts`, byte for byte
`gomodguard_v2` in place of the deprecated `gomodguard`, so the lint no longer (https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
warns, and enables `depguard` with a rule that keeps `net/http/httptest` out in place of the deprecated `gomodguard`, so the lint no longer warns,
of non-test files. Neither raised a finding in this repo. 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 - 2026-10-04: `secret unlocker add pgp` works on Linux
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets the (https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
vault's long-term key as adding a passphrase unlocker does, with the vault's the vault's long-term key as adding a passphrase unlocker does, with the
`GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the mnemonic, vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
checked against the vault, or else from the current unlocker. Before, it used mnemonic, checked against the vault, or else from the current unlocker.
the keychain unlocker's helper, which on every platform but macOS always Before, it used the keychain unlocker's helper, which on every platform
failed. A test adds a PGP unlocker for a throwaway GPG key, getting the but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
long-term key once from the mnemonic and once from a passphrase unlocker, and key, getting the long-term key once from the mnemonic and once from a
reads a secret through the new unlocker. passphrase unlocker, and reads a secret through the new unlocker.
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits, `.`, - 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
`-` and `_`, and must not be empty, `.` or `..` `.`, `-` and `_`, and must not be empty, `.` or `..`
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md` state (https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
the rule. `vault create`, `vault import`, `vault select`, `vault remove`, both state the rule. `vault create`, `vault import`, `vault select`,
vault names of `mv` and shell completion of a `vault:secret` argument check `vault remove`, both vault names of `mv` and shell completion of a
the name as typed with `vault.ValidateVaultName` before building any path from `vault:secret` argument check the name as typed with
it. Before, `vault import ..` wrote a long-term key and an unlocker into the `vault.ValidateVaultName` before building any path from it. Before,
state directory itself, and `vault select ..` made that the current vault. `vault import ..` wrote a long-term key and an unlocker into the state
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged tree directory itself, and `vault select ..` made that the current vault.
(https://git.eeqj.de/sneak/secret/issues/54). It passes the current time as - 2026-10-04: `script/cibuild` runs the checks again on an unchanged
the `CHECK_EPOCH` build argument, which both the lint and the build stage of tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
the `Dockerfile` declare after their module download, so the `RUN` steps below current time as the `CHECK_EPOCH` build argument, which both the lint
the argument run again on each build while the base images and module and the build stage of the `Dockerfile` declare after their module
downloads stay cached. Before, a second run on the same tree took every check download, so the `RUN` steps below the argument run again on each
from the build cache and reported success having run nothing. 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 - 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
directory (https://git.eeqj.de/sneak/secret/issues/48). directory (https://git.eeqj.de/sneak/secret/issues/48).
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for its `secret unlocker add pgp` resolves the GPG key's fingerprint once, for
duplicate check, and passes it to `CreatePGPUnlocker` to record. its duplicate check, and passes it to `CreatePGPUnlocker` to record.
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key and `CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
encrypt everything before writing anything. All four unlocker types write and encrypt everything before writing anything. All four unlocker
their files through `secret.WriteDir`: a new unlocker is built in a temporary types write their files through `secret.WriteDir`: a new unlocker is
directory, renamed into place when complete and removed on a failure. built in a temporary directory, renamed into place when complete and
- 2026-10-04: `secret unlocker select` and `secret unlocker remove` skip, with removed on a failure.
the warning `unlocker list` gives, an unlocker directory whose metadata file - 2026-10-04: `secret unlocker select` and `secret unlocker remove`
cannot be checked for, read or parsed, instead of failing when it sorts before skip, with the warning `unlocker list` gives, an unlocker directory
the unlocker asked for. Such a directory, or one without a metadata file, is whose metadata file cannot be checked for, read or parsed, instead of
removed by its directory name, the name the warning gives; only the directory failing when it sorts before the unlocker asked for. Such a directory,
is removed, since its type is unknown. Removing one whose metadata file is or one without a metadata file, is removed by its directory name, the
missing or corrupt never counts as removing the last unlocker. Removing one name the warning gives; only the directory is removed, since its type
whose metadata file cannot be checked for or read always does, since it may be is unknown. Removing one whose metadata file is missing or corrupt
the only working unlocker, so in a vault with secrets it needs `--force`. never counts as removing the last unlocker. Removing one whose metadata
- 2026-10-04: A failed command prints its error once, without the usage text file cannot be checked for or read always does, since it may be the
after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is still printed only working unlocker, so in a vault with secrets it needs `--force`.
for a command called wrongly: wrong number of arguments, unknown flag, bad - 2026-10-04: A failed command prints its error once, without the usage
flag value, missing required flag, or flags that break a flag group (mutually text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
exclusive, required together, one required). The root command's still printed for a command called wrongly: wrong number of arguments,
`PersistentPreRunE` turns usage off. Cobra checks arguments and flag values unknown flag, bad flag value, missing required flag, or flags that
before that hook but required flags and flag groups only after it, so the hook break a flag group (mutually exclusive, required together, one
checks those two first. Root `SilenceUsage` would have hidden usage for all of required). The root command's `PersistentPreRunE` turns usage off.
these. Cobra checks arguments and flag values before that hook but required
- 2026-10-04: `secret get` keeps the secret in locked memory until it writes it flags and flag groups only after it, so the hook checks those two
out (https://git.eeqj.de/sneak/secret/issues/37): `Vault.GetSecret` and first. Root `SilenceUsage` would have hidden usage for all of these.
`Vault.GetSecretVersion` return a `*memguard.LockedBuffer`, which every caller - 2026-10-04: `secret get` keeps the secret in locked memory until it
destroys, and `secret get` writes its bytes straight to stdout, still with no writes it out (https://git.eeqj.de/sneak/secret/issues/37):
trailing newline. Before, the value was copied into ordinary memory that `Vault.GetSecret` and `Vault.GetSecretVersion` return a
nothing wiped, and `get --version` also wrote it to the debug log. `*memguard.LockedBuffer`, which every caller destroys, and `secret get`
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker targets writes its bytes straight to stdout, still with no trailing newline.
use the local docker daemon, or whatever `DOCKER_HOST` the environment sets. Before, the value was copied into ordinary memory that nothing wiped,
`make build` calls the new `script/build`, which stamps the version (`VERSION` and `get --version` also wrote it to the debug log.
from the environment, else `git describe`) and the git commit as before. - 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
`build`, `clean`, `install` and `docker-run` are in `.PHONY`; `make install` targets use the local docker daemon, or whatever `DOCKER_HOST` the
depends on `build`. The `vet` target is gone: `script/test` runs `go vet` environment sets. `make build` calls the new `script/build`, which
first. stamps the version (`VERSION` from the environment, else
- 2026-10-04: `.gitignore` is the org's standard file, which ignores `.env`, `git describe`) and the git commit as before. `build`, `clean`,
`.env.*`, `*.pem` and `*.key` and editor and OS files, plus this repo's `install` and `docker-run` are in `.PHONY`; `make install` depends on
`/secret`, `*.log`, `*.test` and `settings.local.json` `build`. The `vet` target is gone: `script/test` runs `go vet` first.
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also leaves out - 2026-10-04: `.gitignore` is the org's standard file, which ignores
`node_modules`; `.git` stays in the build context for the version stamp. `.env`, `.env.*`, `*.pem` and `*.key` and editor and OS files, plus
this repo's `/secret`, `*.log`, `*.test` and `settings.local.json`
(https://git.eeqj.de/sneak/secret/issues/40). `.dockerignore` also
leaves out `node_modules`; `.git` stays in the build context for the
version stamp.
- 2026-10-04: `secret init` refuses when the default vault exists, and - 2026-10-04: `secret init` refuses when the default vault exists, and
`secret vault create NAME` when `NAME` does, with "vault NAME already exists", `secret vault create NAME` when `NAME` does, with "vault NAME already
before writing anything. The check is in `vault.CreateVault`, which both exists", before writing anything. The check is in `vault.CreateVault`,
commands call while holding the state directory lock, so two creates of one which both commands call while holding the state directory lock, so two
vault at once cannot both pass the check. Before, either command replaced the creates of one vault at once cannot both pass the check. Before, either
vault's metadata, passphrase unlocker and `longterm.age`, so none of its command replaced the vault's metadata, passphrase unlocker and
secrets could be decrypted any more. Both commands now ask for the unlocker `longterm.age`, so none of its secrets could be decrypted any more. Both
passphrase before creating the vault, so one stopped at that prompt leaves no commands now ask for the unlocker passphrase before creating the vault,
vault behind. so one stopped at that prompt leaves no vault behind.
- 2026-10-04: The `internal/cli` tests are back to about their time before the - 2026-10-04: The `internal/cli` tests are back to about their time
state directory lock (https://git.eeqj.de/sneak/secret/issues/80). The test before the state directory lock
that each changing command waits for the lock releases it as soon as it sees (https://git.eeqj.de/sneak/secret/issues/80). The test that each
the command waiting there, instead of after a fixed 100 ms. The two vaults changing command waits for the lock releases it as soon as it sees the
with passphrase unlockers that the path and move tests start from are made command waiting there, instead of after a fixed 100 ms. The two vaults
once and copied for each test. with passphrase unlockers that the path and move tests start from are
- 2026-10-04: `secret mv` rejects a move whose destination is the source under made once and copied for each test.
another name, such as `foo` for `Foo` on a case-insensitive filesystem (the - 2026-10-04: `secret mv` rejects a move whose destination is the source
macOS default) or a name reached through a symbolic link, before changing under another name, such as `foo` for `Foo` on a case-insensitive
anything, with or without `--force`, within a vault and between vaults; filesystem (the macOS default) or a name reached through a symbolic
before, `--force` removed the destination and so deleted the secret. A rename link, before changing anything, with or without `--force`, within a
that changes only letter case works on a case-sensitive filesystem as before. vault and between vaults; before, `--force` removed the destination and
- 2026-10-04: Lint runs only in docker: `script/lint` builds `Dockerfile.lint`, so deleted the secret. A rename that changes only letter case works on a
where golangci-lint is a build step rebuilt on every run case-sensitive filesystem as before.
(`--no-cache-filter`), so an unchanged tree is linted too; the module download - 2026-10-04: Lint runs only in docker: `script/lint` builds
stays cached. `script/bootstrap` no longer installs golangci-lint, and the `Dockerfile.lint`, where golangci-lint is a build step rebuilt on
`Dockerfile` lint stage calls it directly instead of `make lint`. every run (`--no-cache-filter`), so an unchanged tree is linted too;
`golangci-lint config verify` is not run: it fetches its schema live over the module download stays cached. `script/bootstrap` no longer
unpinned HTTPS. installs golangci-lint, and the `Dockerfile` lint stage calls it
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID no longer directly instead of `make lint`. `golangci-lint config verify` is not
panics: `GetID()` warns with the unlocker's directory and returns run: it fetches its schema live over unpinned HTTPS.
`pgp-unknown`. `ListUnlockers` skips, with a warning, an unlocker whose - 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
metadata file cannot be checked for, read or parsed instead of failing, so no longer panics: `GetID()` warns with the unlocker's directory and
`secret unlocker list` still lists the others; the listing's ID lookup no returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
longer warns about that directory again. unlocker whose metadata file cannot be checked for, read or parsed
- 2026-10-03: `secret mv` rejects a move whose destination is the source instead of failing, so `secret unlocker list` still lists the others;
(`mv --force x x`, `mv --force work:x work:`, or an empty destination, which the listing's ID lookup no longer warns about that directory again.
defaults to the source name) before changing anything; before, `--force` - 2026-10-03: `secret mv` rejects a move whose destination is the
removed the destination first and so deleted the secret. Every vault name source (`mv --force x x`, `mv --force work:x work:`, or an empty
given with `vault:` must be one of the existing vaults by exact name, so destination, which defaults to the source name) before changing
`work:x work/:x` is rejected instead of being taken for a move between two anything; before, `--force` removed the destination first and so
vaults. A move within a named vault no longer makes that vault the current deleted the secret. Every vault name given with `vault:` must be one
one, whether it succeeds or fails. of the existing vaults by exact name, so `work:x work/:x` is rejected
- 2026-10-03: Commands that change the state directory hold one lock (`flock` on instead of being taken for a move between two vaults. A move within a
`lock` in the state directory; a mutex on the in-memory test filesystem), so named vault no longer makes that vault the current one, whether it
concurrent commands no longer lose versions or race on the current pointers. succeeds or fails.
Every file is written through `secret.WriteFileAtomic` (temporary file, sync, - 2026-10-03: Commands that change the state directory hold one lock
rename), so no file is ever half-written and `current`, `currentvault` and (`flock` on `lock` in the state directory; a mutex on the in-memory
`current-unlocker` never go missing. New versions, new secrets and cross-vault test filesystem), so concurrent commands no longer lose versions or
copies are built in a temporary directory and renamed into place, and removals race on the current pointers. Every file is written through
rename out of the way first, so a version or secret is never half-added and `secret.WriteFileAtomic` (temporary file, sync, rename), so no file
never half-removed. is ever half-written and `current`, `currentvault` and
- 2026-10-03: The checks run before changing a vault now stop with an error `current-unlocker` never go missing. New versions, new secrets and
naming the path and cause when they cannot read what they inspect, instead of cross-vault copies are built in a temporary directory and renamed
reading the failure as "nothing there": the duplicate check before into place, and removals rename out of the way first, so a version
`unlocker add pgp` (an unreadable `unlockers.d` or unlocker metadata file), or secret is never half-added and never half-removed. An
the secret count that guards removing the last unlocker and removing a vault, interrupted command can still leave:
and the existing long-term key check before `vault import`. - from `init` or `vault create` killed after the passphrase prompt
- 2026-10-03: `version rm`, `version promote` and `get --version` accept a but before the unlocker is written, a vault with no unlocker,
version only if it is one of the versions `version list` lists for that which `vault create` has already made the current vault;
secret, compared as typed before any path is built (`secret.VersionExists`), - data under a `.tmp-` name in the state directory: a secret,
and touch nothing otherwise. An empty `--version` is rejected instead of version or unlocker being added, or the secret, version, unlocker
meaning the current version. Before, `secret version rm x ../../..` deleted or vault being removed, encrypted keys included. Nothing deletes
the whole vault, `secret version rm x ..` the secret, and `.` or `""` every it; it must be deleted by hand
version. (https://git.eeqj.de/sneak/secret/issues/75).
- 2026-10-03: Key material is wiped on every exit: `Entry()` returns the exit - 2026-10-03: The checks run before changing a vault now stop with an
code after its deferred `memguard.Purge()` has run, and only `main` calls error naming the path and cause when they cannot read what they
`os.Exit`. SIGINT and SIGTERM go through memguard's handler, which wipes every inspect, instead of reading the failure as "nothing there": the
buffer before exiting; when the process is in the terminal's foreground duplicate check before `unlocker add pgp` (an unreadable
process group it first restores the terminal settings from startup, so an `unlockers.d` or unlocker metadata file), the secret count that
interrupted passphrase prompt no longer leaves echo off. guards removing the last unlocker and removing a vault, and the
- 2026-10-03: Every command that builds a path from a secret name checks the existing long-term key check before `vault import`.
name first with `vault.ValidateSecretName` and touches nothing when it is - 2026-10-03: `version rm`, `version promote` and `get --version`
invalid: `rm`, `mv` (both names, within a vault and between vaults, before accept a version only if it is one of the versions `version list`
switching the current vault), `import`, `version list`/`promote`/`rm`, lists for that secret, compared as typed before any path is built
`encrypt` and `decrypt`. The error and `README.md` state the naming rule. (`secret.VersionExists`), and touch nothing otherwise. An empty
Before, `secret rm ..` deleted the whole vault and `secret rm .` every secret `--version` is rejected instead of meaning the current version.
in it. Before, `secret version rm x ../../..` deleted the whole vault,
- 2026-10-03: The keychain unlocker's age key passphrase stays in locked memory: `secret version rm x ..` the secret, and `.` or `""` every version.
it is generated into a locked buffer, and the keychain JSON is written and - 2026-10-03: Key material is wiped on every exit: `Entry()` returns
read by `KeychainData` code in `internal/secret/keychaindata.go` (tested on the exit code after its deferred `memguard.Purge()` has run, and only
Linux) without `encoding/json` holding it; the JSON field names are unchanged. `main` calls `os.Exit`. SIGINT and SIGTERM go through memguard's
- 2026-10-02: A plain `docker build .` builds again: the size tests skip a case handler, which wipes every buffer before exiting; when the process is
that needs more locked memory than the process can lock, and run every case in the terminal's foreground process group it first restores the
under `script/cibuild`. The image stamps the `VERSION` build argument, else terminal settings from startup, so an interrupted passphrase prompt no
`git describe --tags --always`, into `Version`, and fails if `.git` is present longer leaves echo off.
but yields no version; `make build` stamps `git describe` too, not a fixed - 2026-10-03: Every command that builds a path from a secret name
`0.1.0`. `.dockerignore` keeps `.git/config` out; `script/docker` is the checks the name first with `vault.ValidateSecretName` and touches
nothing when it is invalid: `rm`, `mv` (both names, within a vault
and between vaults, before switching the current vault), `import`,
`version list`/`promote`/`rm`, `encrypt` and `decrypt`. The error
and `README.md` state the naming rule. Before, `secret rm ..`
deleted the whole vault and `secret rm .` every secret in it.
- 2026-10-03: The keychain unlocker's age key passphrase stays in
locked memory: it is generated into a locked buffer, and the
keychain JSON is written and read by `KeychainData` code in
`internal/secret/keychaindata.go` (tested on Linux) without
`encoding/json` holding it; the JSON field names are unchanged.
- 2026-10-02: A plain `docker build .` builds again: the size tests
skip a case that needs more locked memory than the process can
lock, and run every case under `script/cibuild`. The image stamps the
`VERSION` build argument, else `git describe --tags --always`, into
`Version`, and fails if `.git` is present but yields no version;
`make build` stamps `git describe` too, not a fixed `0.1.0`.
`.dockerignore` keeps `.git/config` out; `script/docker` is the
canonical copy. canonical copy.
- 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical - 2026-08-07: Updated golangci-lint to v2.12.2 with the canonical
`.golangci.yml` (all linters enabled minus the standard disable list, `lll` `.golangci.yml` (all linters enabled minus the standard disable
88, tests linted); bumped the `Dockerfile` lint-stage image to the tagged list, `lll` 88, tests linted); bumped the `Dockerfile` lint-stage
v2.12.2 Debian digest; fixed all ~1550 new findings across `internal/` and image to the tagged v2.12.2 Debian digest; fixed all ~1550 new
`pkg/` (line wrapping, `wsl_v5` blank lines, sentinel errors for `err113`, findings across `internal/` and `pkg/` (line wrapping, `wsl_v5`
`t.Parallel()` where safe, `_test` package conversions, complexity/`dupl` blank lines, sentinel errors for `err113`, `t.Parallel()` where
helper extraction) on branch `golangci-v2.12.2`. Reworked after review: the safe, `_test` package conversions, complexity/`dupl` helper
`err113` sentinels in `internal/vault`, `internal/secret`, `internal/cli` and extraction) on branch `golangci-v2.12.2`. Reworked after review:
`pkg/bip85` were reshaped so every composed error message is byte-identical to the `err113` sentinels in `internal/vault`, `internal/secret`,
`main`, and `findUnlockerIDByMetadata` now returns an error so `unlocker list` `internal/cli` and `pkg/bip85` were reshaped so every composed
skips an unreadable `unlockers.d` entry with a warning instead of emitting a error message is byte-identical to `main`, and
fabricated fallback ID. `findUnlockerIDByMetadata` now returns an error so `unlocker list`
- 2026-08-07: Added `.editorconfig` skips an unreadable `unlockers.d` entry with a warning instead of
(https://git.eeqj.de/sneak/secret/issues/27). emitting a fabricated fallback ID.
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target; - 2026-03-11: Secure Enclave unlocker for hardware-backed secret
`.gitea/workflows/check.yml` now runs `script/cibuild`. protection, plus review fixes (stub panics, derivation index, tests,
- 2026-03-30: Added the `make fmt-check` target and README) on branch secure-enclave-unlocker.
`.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 protection,
plus review fixes (stub panics, derivation index, tests, README) on branch
secure-enclave-unlocker.
- 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out. - 2026-02-28: Repo cleanup, removed stale .cursorrules and coverage.out.
- Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with missing - Audit fix wave (issues #1, #2, #3, #13, #14): skip unlockers with
metadata, allow uppercase secret names, fix hardcoded derivation index, missing metadata, allow uppercase secret names, fix hardcoded
validate names in GetSecretVersion against path traversal, return errors derivation index, validate names in GetSecretVersion against path
instead of panicking, add Warn() on silent anomalies. traversal, return errors instead of panicking, add Warn() on silent
- Memory security hardening: LockedBuffer used through encrypt/decrypt paths anomalies.
(Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated bare-[]byte APIs - Memory security hardening: LockedBuffer used through encrypt/decrypt
removed. paths (Save/EncryptWithPassphrase/GetValue/gpg helpers), deprecated
- Per-secret keypair architecture, vault package refactor, versioning with bare-[]byte APIs removed.
--version, comprehensive test suite with in-memory filesystem. - Per-secret keypair architecture, vault package refactor, versioning
with --version, comprehensive test suite with in-memory filesystem.
- Debug logging system (slog, GODEBUG flag, TTY-aware output). - Debug logging system (slog, GODEBUG flag, TTY-aware output).
- Renamed SEP unlocker to Keychain, reorganized import commands. - Renamed SEP unlocker to Keychain, reorganized import commands.
- 2025-05-28: Initial implementation (vault, age encryption, mnemonic, CLI). - 2025-05-28: Initial implementation (vault, age encryption, mnemonic,
CLI).
# 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` (`internal/cli/version.go`; `secret version promote` and `secret version rm`
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 `pkg/agehd/agehd_test.go` - Cover mnemonic-vs-xprv identity consistency in
`TestMnemonicVsXPRVConsistency` (was an in-code FIXME removed for godox). `pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
- CI does not compile, lint or test the files built only with cgo on macOS, in-code FIXME removed for godox).
since compiling them needs Apple's SDK: - CI does not compile, lint or test the files built only with cgo on
macOS, since compiling them needs Apple's SDK:
`internal/secret/keychainunlocker_cgo.go` (the three functions that call `internal/secret/keychainunlocker_cgo.go` (the three functions that call
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse` `go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has never (`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
run on them, so it would likely find more there than the line lengths. No never run on them, so it would likely find more there than the line
macOS test runs in CI. A macOS runner would cover all of it (asked on lengths. No macOS test runs in CI. A macOS runner would cover all of it
https://git.eeqj.de/sneak/secret/issues/50). (asked on https://git.eeqj.de/sneak/secret/issues/50).
- Merge secure-enclave-unlocker to main once review is done.
- 1.0 critical security blockers (from repo TODO.md): - 1.0 critical security blockers (from repo TODO.md):
- Memory security: age writes an identity's private key out as a string in - Command injection: GPG key IDs passed unescaped to exec.Command
ordinary memory, and the copies it makes on the way stay there (pgpunlocker.go:323-327); data.String() passed unescaped to the
(`secret.IdentityToLockedBuffer` overwrites only the string itself). security command (keychainunlocker.go:472-476).
- 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.
- 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.
- Cleanups: read statedir from environment or default instead of passing it - mlock/munlock for sensitive allocations.
around. - Cleanups: read statedir from environment or default instead of
- Enhancements: help examples, colored output, --quiet flag, name suggestions on passing it around.
miss, audit logging, hardware integration tests (Keychain, GPG), naming - Enhancements: help examples, shell completion, colored output,
consistency, vault export/import, batch operations, search, secret metadata --quiet flag, name suggestions on miss, audit logging, hardware
integration tests (Keychain, GPG), naming consistency, vault
export/import, batch operations, search, secret metadata
(descriptions, tags). (descriptions, tags).
+1 -1
View File
@@ -4,7 +4,7 @@ package main
import ( import (
"os" "os"
"sneak.berlin/go/secret/internal/cli" "git.eeqj.de/sneak/secret/internal/cli"
) )
func main() { func main() {
+3 -4
View File
@@ -1,4 +1,4 @@
module sneak.berlin/go/secret module git.eeqj.de/sneak/secret
go 1.24.1 go 1.24.1
@@ -9,9 +9,6 @@ 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/dustin/go-humanize v1.0.1
github.com/fatih/color v1.18.0
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
@@ -28,6 +25,8 @@ require (
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
-2
View File
@@ -35,8 +35,6 @@ 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=
+1 -6
View File
@@ -3,13 +3,12 @@ package cli
import ( import (
"fmt" "fmt"
"io"
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
) )
// Instance encapsulates all CLI functionality and state // Instance encapsulates all CLI functionality and state
@@ -22,10 +21,6 @@ type Instance struct {
// none. // none.
Mnemonic *memguard.LockedBuffer Mnemonic *memguard.LockedBuffer
UnlockPassphrase *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
+2 -2
View File
@@ -5,9 +5,9 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
func TestCLIInstanceStateDir(t *testing.T) { func TestCLIInstanceStateDir(t *testing.T) {
+30 -7
View File
@@ -1,13 +1,13 @@
package cli package cli
import ( import (
"maps" "path/filepath"
"slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
) )
// getSecretNamesCompletionFunc returns a completion function that provides // getSecretNamesCompletionFunc returns a completion function that provides
@@ -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, the names of the unlockers' directories in unlockers.d // unlocker IDs
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,15 +57,38 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
return nil, cobra.ShellCompDirectiveNoFileComp return nil, cobra.ShellCompDirectiveNoFileComp
} }
unlockerMetadata, err := vlt.ListUnlockers() // Get unlocker metadata list
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
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) { unlockersDir := filepath.Join(vaultDir, "unlockers.d")
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)
} }
} }
-108
View File
@@ -1,108 +0,0 @@
package cli
import (
"bufio"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/spf13/cobra"
"golang.org/x/term"
"sneak.berlin/go/secret/internal/vault"
)
// 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
@@ -1,411 +0,0 @@
// 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"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
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)
}
+10 -229
View File
@@ -2,21 +2,16 @@ package cli_test
import ( import (
"bytes" "bytes"
"io"
"maps"
"os" "os"
"os/exec"
"slices"
"strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// TestCreateExistingVaultChangesNothing is a regression test for // TestCreateExistingVaultChangesNothing is a regression test for
@@ -67,18 +62,22 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
tests := []struct { tests := []struct {
command string command string
want string
run func(c *cli.Instance) error run func(c *cli.Instance) error
}{ }{
{ {
"init", "init",
"failed to create default vault: vault default already exists",
func(c *cli.Instance) error { return c.Init(cmd) }, func(c *cli.Instance) error { return c.Init(cmd) },
}, },
{ {
"vault create default", "vault create default",
"vault default already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") }, func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
}, },
{ {
"vault create work", "vault create work",
"vault work already exists",
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") }, func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
}, },
} }
@@ -89,7 +88,7 @@ func TestCreateExistingVaultChangesNothing(t *testing.T) {
err := tt.run(newCLI(fs)) err := tt.run(newCLI(fs))
require.ErrorIs(t, err, vault.ErrVaultExists) require.EqualError(t, err, tt.want)
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
}) })
} }
@@ -156,7 +155,7 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms)) require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
withDefault := afero.NewMemMapFs() withDefault := afero.NewMemMapFs()
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil) _, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic)
require.NoError(t, err) require.NoError(t, err)
cmd := &cobra.Command{} cmd := &cobra.Command{}
@@ -189,226 +188,8 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
err := tt.run(c) err := tt.run(c)
require.ErrorIs(t, err, secret.ErrPassphraseNotRead) require.ErrorContains(t, err, "failed to read passphrase")
require.Equal(t, before, snapshotStateDir(t, tt.fs)) require.Equal(t, before, snapshotStateDir(t, tt.fs))
}) })
} }
} }
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
// read mnemonic: failed to read passphrase: ...". The error must wrap
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
// pinned on the built binary, whose stdin is surely not a terminal.
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
t.Parallel()
c := cli.NewCLIInstanceWithStateDir(afero.NewMemMapFs(), testStateDir)
require.ErrorIs(t, c.Init(discardCmd()), secret.ErrMnemonicNotRead)
stateDir := t.TempDir()
//nolint:gosec // G204: test executes the freshly built secret binary
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "init")
cmd.Env = []string{
secret.EnvStateDir + "=" + stateDir,
"PATH=" + os.Getenv("PATH"),
"HOME=" + os.Getenv("HOME"),
}
output, err := cmd.CombinedOutput()
require.Error(t, err)
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
"or run interactively\n", string(output))
}
// 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", 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", 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
// vault.ErrVaultExists.
func requireStopsLeaveWholeVaultOrNone(
t *testing.T, fs afero.Fs, name 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.ErrorIs(t, run(c), vault.ErrVaultExists)
} 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)
}
+25 -6
View File
@@ -7,16 +7,17 @@ import (
"os" "os"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// Sentinel errors for encrypt/decrypt operations // Sentinel errors for encrypt/decrypt operations
var ( var (
errNotAgeSecretKey = errors.New( errNotAgeSecretKey = errors.New(
"does not contain a valid age secret key") "does not contain a valid age secret key")
errSecretDoesNotExist = errors.New("does not exist")
) )
// newCryptoCmd builds an encrypt/decrypt command with input/output flags // newCryptoCmd builds an encrypt/decrypt command with input/output flags
@@ -90,7 +91,8 @@ 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)
} }
secureBuffer := secret.IdentityToLockedBuffer(identity) // Store the generated key directly in a secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
err = vlt.AddSecret(secretName, secureBuffer, false) err = vlt.AddSecret(secretName, secureBuffer, false)
if err != nil { if err != nil {
@@ -129,7 +131,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 := vlt.GetSecret(secretName) secretBuffer, err := cli.getSecretValue(vlt, secretObj)
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)
} }
@@ -244,11 +246,11 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound) return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
} }
// Get the age secret key from the secret // Get the age secret key from the secret
secretBuffer, err := vlt.GetSecret(secretName) secretBuffer, err := cli.getSecretValue(vlt, secretObj)
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)
} }
@@ -312,3 +314,20 @@ func isValidAgeSecretKey(key string) bool {
return err == nil return err == nil
} }
// getSecretValue retrieves the value of a secret with the vault's mnemonic
// when it has one, else with the current unlocker
func (cli *Instance) getSecretValue(
vlt *vault.Vault, secretObj *secret.Secret,
) (*memguard.LockedBuffer, error) {
if vlt.Mnemonic != nil {
return secretObj.GetValue(nil, vlt.Mnemonic)
}
unlocker, err := vlt.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
return secretObj.GetValue(unlocker, nil)
}
+2 -2
View File
@@ -10,11 +10,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
// Entry must return its exit code rather than exit, so that its deferred // Entry must return its exit code rather than exit, so that its deferred
-62
View File
@@ -1,62 +0,0 @@
package cli_test
import (
"testing"
"github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/vault"
)
// TestMissingSecretOrVaultErrors checks that a command that finds no such
// secret or vault returns the vault package's error for it, as `secret get`
// does, and leaves the vaults unchanged. "default" is the current vault, and
// both vaults hold the secret "x".
func TestMissingSecretOrVaultErrors(t *testing.T) {
t.Parallel()
before := snapshotStateDir(t, newTwoVaultFs(t))
tests := []struct {
command string
want error
run func(c *cli.Instance) error
}{
{
"rm --force nosuch", vault.ErrSecretNotFound,
func(c *cli.Instance) error {
return c.RemoveSecret(&cobra.Command{}, "nosuch", true)
},
},
{
"version rm --force nosuch", vault.ErrSecretNotFound,
func(c *cli.Instance) error {
return c.RemoveVersion(&cobra.Command{}, "nosuch", "20260101.001", true)
},
},
{
"mv --force work:nosuch default", vault.ErrSecretNotFound,
func(c *cli.Instance) error {
return c.MoveSecret(&cobra.Command{}, "work:nosuch", "default", true)
},
},
{
"decrypt nosuch", vault.ErrSecretNotFound,
func(c *cli.Instance) error { return c.Decrypt("nosuch", "", "") },
},
{
"vault rm --force nosuch", vault.ErrVaultNotFound,
func(c *cli.Instance) error {
return c.RemoveVault(&cobra.Command{}, "nosuch", true)
},
},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
requireRejectedAndUnchanged(t, before, tt.want, tt.run)
})
}
}
+9 -6
View File
@@ -7,10 +7,10 @@ import (
"math/big" "math/big"
"os" "os"
"git.eeqj.de/sneak/secret/internal/vault"
"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"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -20,7 +20,11 @@ const (
// Sentinel errors for secret generation // Sentinel errors for secret generation
var ( var (
errLengthTooSmall = errors.New("length must be at least 1") errLengthTooSmall = errors.New("length must be at least 1")
errLengthNotPositive = errors.New("length must be positive")
errMnemonicTypeNotSupported = errors.New(
"mnemonic type not supported for secret generation, " +
"use 'secret generate mnemonic' instead")
errUnsupportedSecretType = errors.New("unsupported type") errUnsupportedSecretType = errors.New("unsupported type")
) )
@@ -144,8 +148,7 @@ func (cli *Instance) GenerateSecret(
case "alnum": case "alnum":
secretValue, err = generateRandomAlnum(length) secretValue, err = generateRandomAlnum(length)
case "mnemonic": case "mnemonic":
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)", return errMnemonicTypeNotSupported
errUnsupportedSecretType)
default: default:
return fmt.Errorf("%w: %s (supported: base58, alnum)", return fmt.Errorf("%w: %s (supported: base58, alnum)",
errUnsupportedSecretType, secretType) errUnsupportedSecretType, secretType)
@@ -201,8 +204,8 @@ func generateRandomAlnum(length int) (string, error) {
// generateRandomString generates a random string of the specified length // generateRandomString generates a random string of the specified length
// using the given character set // using the given character set
func generateRandomString(length int, charset string) (string, error) { func generateRandomString(length int, charset string) (string, error) {
if length < 1 { if length <= 0 {
return "", errLengthTooSmall return "", errLengthNotPositive
} }
result := make([]byte, length) result := make([]byte, length)
+1 -1
View File
@@ -10,11 +10,11 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/vault"
) )
// Version info - these are set at build time // Version info - these are set at build time
+1 -1
View File
@@ -4,8 +4,8 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// vaultStats accumulates statistics while walking vault directories // vaultStats accumulates statistics while walking vault directories
+66 -23
View File
@@ -6,13 +6,16 @@ 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/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"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// errPassphraseMismatch is returned when passphrase confirmation fails // errPassphraseMismatch is returned when passphrase confirmation fails
@@ -55,11 +58,11 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
secret.Debug("Prompting user for mnemonic phrase") secret.Debug("Prompting user for mnemonic phrase")
// Read mnemonic securely without echo // Read mnemonic securely without echo
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ") mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
if err != nil { if err != nil {
secret.Debug("Failed to read mnemonic from stdin", "error", err) secret.Debug("Failed to read mnemonic from stdin", "error", err)
return nil, nil, 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
@@ -67,6 +70,43 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
return mnemonicBuffer, 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 string, mnemonic *memguard.LockedBuffer,
) (*vault.Vault, *age.X25519Identity, error) {
// Create the default vault - it will handle key derivation internally
secret.Debug("Creating default vault")
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default", mnemonic)
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(mnemonic.String(), 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
// while initialize runs // while initialize runs
func (cli *Instance) Init(cmd *cobra.Command) error { func (cli *Instance) Init(cmd *cobra.Command) error {
@@ -133,31 +173,34 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
} }
defer cleanupPassphrase() defer cleanupPassphrase()
// Create the default vault with its passphrase unlocker // Create the default vault and derive its long-term key
secret.Debug("Creating default vault") vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonic)
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
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", ltIdentity.Recipient().String()) cmd.Printf("Long-term public key: %s\n", ltPubKey)
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID()) cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.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.")
-67
View File
@@ -1,67 +0,0 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
)
// TestInvalidMnemonicError checks that every command that takes a mnemonic
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
// "other" has no long-term key, as vault import needs.
func TestInvalidMnemonicError(t *testing.T) {
t.Parallel()
tests := []struct {
command string
run func(c *Instance) error
}{
{"secret init", func(c *Instance) error { return c.Init(c.cmd) }},
{"secret vault create work", func(c *Instance) error {
return c.CreateVault(c.cmd, "work")
}},
{"secret vault import other", func(c *Instance) error {
return c.VaultImport(c.cmd, "other")
}},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
require.NoError(t, err)
instance, _ := newTestInstance(fs)
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
t.Cleanup(instance.Mnemonic.Destroy)
require.ErrorIs(t, tt.run(instance), errInvalidMnemonicPhrase)
})
}
}
// TestGenerateSecretErrors checks that `secret generate secret` gives one
// error for a length below 1 and one for a type it cannot generate.
func TestGenerateSecretErrors(t *testing.T) {
t.Parallel()
instance, cmd := newTestInstance(afero.NewMemMapFs())
err := instance.GenerateSecret(cmd, "x", 0, "base58", false)
require.ErrorIs(t, err, errLengthTooSmall)
_, err = generateRandomString(0, "ab")
require.ErrorIs(t, err, errLengthTooSmall)
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "mnemonic", false)
require.ErrorIs(t, err, errUnsupportedSecretType)
err = instance.GenerateSecret(cmd, "x", defaultSecretLength, "hex", false)
require.ErrorIs(t, err, errUnsupportedSecretType)
}
+57 -187
View File
@@ -2,13 +2,10 @@
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"
@@ -17,15 +14,11 @@ import (
"testing" "testing"
"time" "time"
"github.com/awnumar/memguard" "git.eeqj.de/sneak/secret/internal/cli"
"github.com/creack/pty" "git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
@@ -680,10 +673,10 @@ func test06GetSecret(t *testing.T, testMnemonic string, runSecret func(...string
require.NoError(t, err, "get secret should succeed") require.NoError(t, err, "get secret should succeed")
assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value") assert.Equal(t, "password123", strings.TrimSpace(output), "should return correct secret value")
// Test that without mnemonic, we get an error: the passphrase unlocker // Test that without mnemonic, we get an error
// cannot ask for its passphrase, as the tests have no terminal output, err = runSecret("get", "database/password")
_, err = runSecret("get", "database/password") require.Error(t, err, "get should fail without unlock method")
require.ErrorIs(t, err, secret.ErrPassphraseNotRead, "get should fail without unlock method") assert.Contains(t, output, "failed to unlock vault", "should indicate unlock failure")
} }
func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { func test07AddSecretVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
@@ -839,11 +832,12 @@ func test09GetSpecificVersion(t *testing.T, tempDir, testMnemonic string, runSec
assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version") assert.Equal(t, "newpassword456", strings.TrimSpace(output), "should return new secret value without --version")
// An empty --version is not a version; it does not mean the current one // An empty --version is not a version; it does not mean the current one
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "--version", "", "database/password") }, "get", "--version", "", "database/password")
require.ErrorIs(t, err, vault.ErrVersionNotFound, "should reject the empty version") require.Error(t, err, "get with an empty version should fail")
assert.Contains(t, output, "version '' not found", "should reject the empty version")
} }
func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) { func test10PromoteVersion(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
@@ -1157,7 +1151,11 @@ func testInvalidSecretNames(t *testing.T, testMnemonic string, runSecretWithStdi
shouldFail := slices.Contains(definitelyInvalid, invalidName) shouldFail := slices.Contains(definitelyInvalid, invalidName)
if shouldFail { if shouldFail {
require.ErrorIs(t, err, vault.ErrInvalidSecretName, "add '%s' should fail", invalidName) require.Error(t, err, "add '%s' should fail", invalidName)
if err != nil {
assert.Contains(t, output, "invalid secret name", "should indicate invalid name for '%s'", invalidName)
}
} else { } else {
// For the slash cases and .hidden, they might succeed // For the slash cases and .hidden, they might succeed
// Just log what happened // Just log what happened
@@ -1216,8 +1214,9 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
// Test error cases // Test error cases
// Try to move non-existent secret // Try to move non-existent secret
_, err = runSecret("move", "test/nonexistent", "test/destination") output, err = runSecret("move", "test/nonexistent", "test/destination")
require.ErrorIs(t, err, vault.ErrSecretNotFound, "move non-existent should fail") require.Error(t, err, "move non-existent should fail")
assert.Contains(t, output, "not found", "should indicate source not found")
// Try to move to existing destination // Try to move to existing destination
_, err = runSecretWithStdin("dest-value", map[string]string{ _, err = runSecretWithStdin("dest-value", map[string]string{
@@ -1225,8 +1224,9 @@ func test12bMoveSecret(t *testing.T, testMnemonic string, runSecret func(...stri
}, "add", "test/existing-dest") }, "add", "test/existing-dest")
require.NoError(t, err, "add test/existing-dest should succeed") require.NoError(t, err, "add test/existing-dest should succeed")
_, err = runSecret("move", "test/renamed", "test/existing-dest") output, err = runSecret("move", "test/renamed", "test/existing-dest")
require.ErrorIs(t, err, vault.ErrSecretExists, "move to existing destination should fail") require.Error(t, err, "move to existing destination should fail")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Verify the source wasn't removed since move failed // Verify the source wasn't removed since move failed
getOutput, err = runSecretWithEnv(map[string]string{ getOutput, err = runSecretWithEnv(map[string]string{
@@ -1303,8 +1303,9 @@ func test12cCrossVaultMove(t *testing.T, testMnemonic string, runSecretWithEnv f
require.NoError(t, err, "add force/test in work should succeed") require.NoError(t, err, "add force/test in work should succeed")
// Move without force should fail // Move without force should fail
_, err = runSecretWithEnv(env, "move", "work:force/test", "default") output, err = runSecretWithEnv(env, "move", "work:force/test", "default")
require.ErrorIs(t, err, vault.ErrSecretExists, "move without force should fail when dest exists") require.Error(t, err, "move without force should fail when dest exists")
assert.Contains(t, output, "already exists", "should indicate destination exists")
// Move with force should succeed // Move with force should succeed
output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default") output, err = runSecretWithEnv(env, "move", "--force", "work:force/test", "default")
@@ -1419,8 +1420,9 @@ func test14SwitchVault(t *testing.T, tempDir string, runSecret func(...string) (
require.NoError(t, err, "vault select default should succeed") require.NoError(t, err, "vault select default should succeed")
// Test selecting non-existent vault // Test selecting non-existent vault
_, err = runSecret("vault", "select", "nonexistent") output, err := runSecret("vault", "select", "nonexistent")
require.ErrorIs(t, err, vault.ErrVaultNotFound, "selecting non-existent vault should fail") require.Error(t, err, "selecting non-existent vault should fail")
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
} }
func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) { func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
@@ -1441,10 +1443,11 @@ func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...s
require.NoError(t, err, "vault select work should succeed") require.NoError(t, err, "vault select work should succeed")
// Try to get the default-only secret (should fail) // Try to get the default-only secret (should fail)
_, err = runSecretWithEnv(map[string]string{ output, err := runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "default-only/secret") }, "get", "default-only/secret")
require.ErrorIs(t, err, vault.ErrSecretNotFound, "should not be able to get default vault secret from work vault") require.Error(t, err, "should not be able to get default vault secret from work vault")
assert.Contains(t, output, "not found", "should indicate secret not found")
// Add a unique secret to work vault // Add a unique secret to work vault
_, err = runSecretWithStdin("work-vault-secret", map[string]string{ _, err = runSecretWithStdin("work-vault-secret", map[string]string{
@@ -1457,13 +1460,14 @@ func test15VaultIsolation(t *testing.T, testMnemonic string, runSecret func(...s
require.NoError(t, err, "vault select default should succeed") require.NoError(t, err, "vault select default should succeed")
// Try to get the work-only secret (should fail) // Try to get the work-only secret (should fail)
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "work-only/secret") }, "get", "work-only/secret")
require.ErrorIs(t, err, vault.ErrSecretNotFound, "should not be able to get work vault secret from default vault") require.Error(t, err, "should not be able to get work vault secret from default vault")
assert.Contains(t, output, "not found", "should indicate secret not found")
// Verify we can still get the default-only secret // Verify we can still get the default-only secret
output, err := runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "default-only/secret") }, "get", "default-only/secret")
require.NoError(t, err, "get default-only secret should succeed") require.NoError(t, err, "get default-only secret should succeed")
@@ -1575,10 +1579,11 @@ func test17ImportFromFile(t *testing.T, tempDir, testMnemonic string, runSecretW
// Just verify the import succeeded // Just verify the import succeeded
// Test importing non-existent file // Test importing non-existent file
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "import", "imported/nonexistent", "--source", "/nonexistent/file") }, "import", "imported/nonexistent", "--source", "/nonexistent/file")
require.ErrorIs(t, err, os.ErrNotExist, "importing non-existent file should fail") require.Error(t, err, "importing non-existent file should fail")
assert.Contains(t, output, "failed", "should indicate failure")
// Verify filesystem structure // Verify filesystem structure
defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default") defaultVaultDir := filepath.Join(tempDir, "vaults.d", "default")
@@ -1893,10 +1898,11 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
t.Helper() t.Helper()
// Get non-existent secret // Get non-existent secret
_, err := runSecretWithEnv(map[string]string{ output, err := runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "nonexistent/secret") }, "get", "nonexistent/secret")
require.ErrorIs(t, err, vault.ErrSecretNotFound, "get non-existent secret should fail") require.Error(t, err, "get non-existent secret should fail")
assert.Contains(t, output, "not found", "should indicate secret not found")
// Add secret without mnemonic or unlocker // Add secret without mnemonic or unlocker
unsetMnemonic := os.Getenv(secret.EnvMnemonic) unsetMnemonic := os.Getenv(secret.EnvMnemonic)
@@ -1926,28 +1932,32 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
// Invalid secret names (already tested in test 12) // Invalid secret names (already tested in test 12)
// Non-existent vault operations // Non-existent vault operations
_, err = runSecret("vault", "select", "nonexistent") output, err = runSecret("vault", "select", "nonexistent")
require.ErrorIs(t, err, vault.ErrVaultNotFound, "select non-existent vault should fail") require.Error(t, err, "select non-existent vault should fail")
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
// Import to non-existent vault with test passphrase // Import to non-existent vault with test passphrase
testPassphrase := "test-passphrase-123" // Define testPassphrase locally testPassphrase := "test-passphrase-123" // Define testPassphrase locally
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
secret.EnvUnlockPassphrase: testPassphrase, secret.EnvUnlockPassphrase: testPassphrase,
}, "vault", "import", "nonexistent") }, "vault", "import", "nonexistent")
require.ErrorIs(t, err, vault.ErrVaultNotFound, "import to non-existent vault should fail") require.Error(t, err, "import to non-existent vault should fail")
assert.Contains(t, output, "does not exist", "should indicate vault doesn't exist")
// Get specific version that doesn't exist // Get specific version that doesn't exist
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "--version", "99999999.999", "database/password") }, "get", "--version", "99999999.999", "database/password")
require.ErrorIs(t, err, vault.ErrVersionNotFound, "get non-existent version should fail") require.Error(t, err, "get non-existent version should fail")
assert.Contains(t, output, "not found", "should indicate version not found")
// Promote non-existent version // Promote non-existent version
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "version", "promote", "database/password", "99999999.999") }, "version", "promote", "database/password", "99999999.999")
require.ErrorIs(t, err, vault.ErrVersionNotFound, "promote non-existent version should fail") require.Error(t, err, "promote non-existent version should fail")
assert.Contains(t, output, "not found", "should indicate version not found")
} }
func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) { func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic, testPassphrase string) {
@@ -2350,10 +2360,11 @@ func test30BackupRestore(t *testing.T, tempDir, secretPath, testMnemonic string,
assert.NotEmpty(t, output, "restored secret should have value") assert.NotEmpty(t, output, "restored secret should have value")
// Verify post-backup secret is gone // Verify post-backup secret is gone
_, err = runSecretWithEnv(map[string]string{ output, err = runSecretWithEnv(map[string]string{
secret.EnvMnemonic: testMnemonic, secret.EnvMnemonic: testMnemonic,
}, "get", "post-backup/secret") }, "get", "post-backup/secret")
require.ErrorIs(t, err, vault.ErrSecretNotFound, "post-backup secret should not exist after restore") require.Error(t, err, "post-backup secret should not exist after restore")
assert.Contains(t, output, "not found", "should indicate secret not found")
t.Log("Backup and restore completed successfully") t.Log("Backup and restore completed successfully")
} }
@@ -2418,7 +2429,8 @@ func test31EnvMnemonicUsesVaultDerivationIndex(t *testing.T, tempDir, secretPath
t.Logf("Output: %s", getOutput) t.Logf("Output: %s", getOutput)
// This is the expected behavior with the current bug // This is the expected behavior with the current bug
require.ErrorIs(t, err, vault.ErrMnemonicMismatch, "get should fail due to wrong derivation index") require.Error(t, err, "get should fail due to wrong derivation index")
assert.Contains(t, getOutput, "derived public key does not match vault", "should indicate key derivation failure")
// Document what should happen when the bug is fixed // Document what should happen when the bug is fixed
t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1") t.Log("When the bug is fixed, GetValue should read vault metadata and use derivation index 1")
@@ -2531,145 +2543,3 @@ 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
@@ -1,74 +0,0 @@
package cli_test
import (
"io"
"testing"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// 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))
}
+18 -22
View File
@@ -13,13 +13,13 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -110,7 +110,7 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
{"real", afero.NewOsFs(), t.TempDir()}, {"real", afero.NewOsFs(), t.TempDir()},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil) _, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic)
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
@@ -185,7 +185,7 @@ func (r *readNotifier) Read(p []byte) (int, error) {
//nolint:paralleltest // times commands against the in-memory lock all tests share //nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptPipedIntoAdd(t *testing.T) { func TestEncryptPipedIntoAdd(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
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))
@@ -241,10 +241,8 @@ 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. Without // Fails once it holds the lock: there is no current vault
// --force it would fail before taking the lock, on the check it makes err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
// before asking.
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
require.Error(t, err) require.Error(t, err)
select { select {
@@ -292,14 +290,14 @@ func setupEveryCommand(
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil) other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic)
require.NoError(t, err) require.NoError(t, err)
otherDir, err := other.GetDirectory() otherDir, err := other.GetDirectory()
require.NoError(t, err) require.NoError(t, err)
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age"))) require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil) vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic)
require.NoError(t, err) require.NoError(t, err)
addTestSecret(t, vlt, []byte("older"), false) addTestSecret(t, vlt, []byte("older"), false)
@@ -362,6 +360,7 @@ 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)
@@ -371,9 +370,6 @@ 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)) unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer unlockPassphrase.Destroy() defer unlockPassphrase.Destroy()
@@ -435,8 +431,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 --force", false, func(cli *Instance, _, _ string) error { {"rm", false, func(cli *Instance, _, _ string) error {
return cli.RemoveSecret(cli.cmd, "test/secret", true) return cli.RemoveSecret(cli.cmd, "test/secret", false)
}}, }},
{"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)
@@ -444,8 +440,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 --force", false, func(cli *Instance, olderVersion, _ string) error { {"version rm", false, func(cli *Instance, olderVersion, _ string) error {
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true) return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion)
}}, }},
{"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")
@@ -456,13 +452,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 --force", false, func(cli *Instance, _, _ string) error { {"vault rm", false, func(cli *Instance, _, _ string) error {
return cli.RemoveVault(cli.cmd, "other", true) return cli.RemoveVault(cli.cmd, "other", false)
}}, }},
{"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 --force", true, func(cli *Instance, _, unlockerID string) error { {"unlocker rm", 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 {
@@ -487,7 +483,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil) _, err := vault.CreateVault(fs, testStateDir, "default", mnemonic)
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))
@@ -525,7 +521,7 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
//nolint:paralleltest // times commands against the in-memory lock all tests share //nolint:paralleltest // times commands against the in-memory lock all tests share
func TestEncryptStreamsUnlocked(t *testing.T) { func TestEncryptStreamsUnlocked(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
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))
+29 -54
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/vault"
) )
// TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for // TestRejectedMoveWithinVaultLeavesStateUnchanged is a regression test for
@@ -30,8 +30,6 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
workX = "work:x" workX = "work:x"
) )
// internal/cli declares these errors itself and does not export them, so
// only their text can be compared.
tests := []struct { tests := []struct {
command string command string
source, dest string source, dest string
@@ -45,6 +43,30 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
{`mv --force work:x ""`, workX, "", true, ontoItself}, {`mv --force work:x ""`, workX, "", true, ontoItself},
// "work" is a vault name, so the destination is work:x. // "work" is a vault name, so the destination is work:x.
{"mv --force work:x work", workX, "work", true, ontoItself}, {"mv --force work:x work", workX, "work", true, ontoItself},
{
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
"secret 'nosuch' not found",
},
// Only an existing vault is used.
{
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
"vault 'nosuch' does not exist",
},
// Each of these spells "work" a second way. The spelling is not a
// valid vault name, so the move is not taken for a move between two
// vaults, which would delete the destination, here the source.
{
"mv --force work:x work/:x", workX, "work/:x", true,
vault.ValidateVaultName("work/").Error(),
},
{
"mv --force work/:x work:", "work/:x", "work:", true,
vault.ValidateVaultName("work/").Error(),
},
{
"mv --force work:x ./work:x", workX, "./work:x", true,
vault.ValidateVaultName("./work").Error(),
},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -60,53 +82,6 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
require.EqualError(t, err, tt.wantErr) require.EqualError(t, err, tt.wantErr)
}) })
} }
missing := []struct {
command string
source, dest string
force bool
want error
}{
{
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
vault.ErrSecretNotFound,
},
// Only an existing vault is used.
{
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
vault.ErrVaultNotFound,
},
}
for _, tt := range missing {
t.Run(tt.command, func(t *testing.T) {
t.Parallel()
requireRejectedAndUnchanged(t, before, tt.want, func(c *cli.Instance) error {
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
})
})
}
// Each of these spells "work" a second way. The spelling is not a valid
// vault name, so the move is not taken for a move between two vaults,
// which would delete the destination, here the source.
invalidNames := []struct{ source, dest string }{
{workX, "work/:x"},
{"work/:x", "work:"},
{workX, "./work:x"},
}
for _, tt := range invalidNames {
t.Run("mv --force "+tt.source+" "+tt.dest, func(t *testing.T) {
t.Parallel()
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
func(c *cli.Instance) error {
return c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, true)
})
})
}
} }
// TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x // TestMoveWithinOtherVaultKeepsCurrentVault checks that `secret mv work:x
@@ -181,10 +156,10 @@ func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
vaultsDir := filepath.Join(stateDir, "vaults.d") vaultsDir := filepath.Join(stateDir, "vaults.d")
// "default" is created last, so it is the current vault. // "default" is created last, so it is the current vault.
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil) vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
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)
@@ -230,7 +205,7 @@ func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
stateDir := t.TempDir() stateDir := t.TempDir()
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil) vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t))
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)
+51 -53
View File
@@ -9,13 +9,13 @@ import (
"sync" "sync"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -68,7 +68,7 @@ func newTwoVaultFs(t *testing.T) afero.Fs {
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
for _, name := range []string{"work", "default"} { for _, name := range []string{"work", "default"} {
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil) vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic)
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)
@@ -90,8 +90,6 @@ 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()
@@ -104,10 +102,6 @@ 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+"/"] = ""
@@ -154,10 +148,11 @@ func newFsFromSnapshot(t *testing.T, tree map[string]string) afero.Fs {
} }
// requireRejectedAndUnchanged runs a command on a copy of the state // requireRejectedAndUnchanged runs a command on a copy of the state
// directory recorded in before. It requires the error want, so that a later // directory recorded in before. It requires an error with exactly the
// check rejecting the argument does not count, and everything under the // message of want, so that a later check rejecting the argument does not
// state directory as it was: the error alone proves nothing, since it could // count, and everything under the state directory as it was: the error
// come after the vault had already been deleted. // alone proves nothing, since it could come after the vault had already
// been deleted.
func requireRejectedAndUnchanged( func requireRejectedAndUnchanged(
t *testing.T, before map[string]string, want error, t *testing.T, before map[string]string, want error,
run func(c *cli.Instance) error, run func(c *cli.Instance) error,
@@ -169,14 +164,14 @@ func requireRejectedAndUnchanged(
err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir)) err := run(cli.NewCLIInstanceWithStateDir(fs, testStateDir))
require.Equal(t, before, snapshotStateDir(t, fs)) require.Equal(t, before, snapshotStateDir(t, fs))
require.ErrorIs(t, err, want) require.EqualError(t, err, want.Error())
} }
// 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.
// Removals, moves and imports use --force, so that only the name check // Moves and imports use --force, so that only the name check stands in
// stands in the way. // the way.
// //
//nolint:paralleltest // the cases share cmd //nolint:paralleltest // the cases share cmd
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) { func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
@@ -192,76 +187,77 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
cmd := &cobra.Command{} cmd := &cobra.Command{}
tests := []struct { tests := []struct {
command string command string
run func(c *cli.Instance) error rejected string // the secret name the command must reject
run func(c *cli.Instance) error
}{ }{
{"rm --force ..", func(c *cli.Instance) error { {"rm ..", "..", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "..", true) return c.RemoveSecret(cmd, "..", false)
}}, }},
{"rm --force .", func(c *cli.Instance) error { {"rm .", ".", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, ".", true) return c.RemoveSecret(cmd, ".", false)
}}, }},
{`rm --force ""`, func(c *cli.Instance) error { {`rm ""`, "", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "", true) return c.RemoveSecret(cmd, "", false)
}}, }},
{"rm --force ../../etc", func(c *cli.Instance) error { {"rm ../../etc", "../../etc", func(c *cli.Instance) error {
return c.RemoveSecret(cmd, "../../etc", true) return c.RemoveSecret(cmd, "../../etc", false)
}}, }},
{"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)
}}, }},
{"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)
}}, }},
{`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)
}}, }},
// "work" is not the current vault: a move within it must not // "work" is not the current vault: a move within it must not
// select it when a name is rejected. // select it when a name is rejected.
{"mv --force work:.. work:x", func(c *cli.Instance) error { {"mv --force work:.. work:x", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "work:..", "work:x", true) return c.MoveSecret(cmd, "work:..", "work:x", true)
}}, }},
{"mv --force work:x work:..", func(c *cli.Instance) error { {"mv --force work:x work:..", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "work:x", "work:..", true) return c.MoveSecret(cmd, "work:x", "work:..", true)
}}, }},
{"mv --force default:.. work", func(c *cli.Instance) error { {"mv --force default:.. work", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:..", "work", true) return c.MoveSecret(cmd, "default:..", "work", true)
}}, }},
{"mv --force default:.. work:y", func(c *cli.Instance) error { {"mv --force default:.. work:y", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:..", "work:y", true) return c.MoveSecret(cmd, "default:..", "work:y", true)
}}, }},
{"mv --force default:x work:..", func(c *cli.Instance) error { {"mv --force default:x work:..", "..", func(c *cli.Instance) error {
return c.MoveSecret(cmd, "default:x", "work:..", true) return c.MoveSecret(cmd, "default:x", "work:..", true)
}}, }},
{"import --force ..", func(c *cli.Instance) error { {"import --force ..", "..", func(c *cli.Instance) error {
return c.ImportSecret(cmd, "..", missingFile, true) return c.ImportSecret(cmd, "..", missingFile, true)
}}, }},
{"import --force .", func(c *cli.Instance) error { {"import --force .", ".", func(c *cli.Instance) error {
return c.ImportSecret(cmd, ".", missingFile, true) return c.ImportSecret(cmd, ".", missingFile, true)
}}, }},
{"import --force ../../etc", func(c *cli.Instance) error { {"import --force ../../etc", "../../etc", func(c *cli.Instance) error {
return c.ImportSecret(cmd, "../../etc", missingFile, true) return c.ImportSecret(cmd, "../../etc", missingFile, true)
}}, }},
{"version list ..", func(c *cli.Instance) error { {"version list ..", "..", func(c *cli.Instance) error {
return c.ListVersions(cmd, "..") return c.ListVersions(cmd, "..")
}}, }},
{"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 --force ..", func(c *cli.Instance) error { {"version rm ..", "..", func(c *cli.Instance) error {
return c.RemoveVersion(cmd, "..", testVersion, true) return c.RemoveVersion(cmd, "..", testVersion)
}}, }},
{"encrypt ..", func(c *cli.Instance) error { {"encrypt ..", "..", func(c *cli.Instance) error {
return c.Encrypt("..", "", "") return c.Encrypt("..", "", "")
}}, }},
{"decrypt ..", func(c *cli.Instance) error { {"decrypt ..", "..", func(c *cli.Instance) error {
return c.Decrypt("..", "", "") return c.Decrypt("..", "", "")
}}, }},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) { t.Run(tt.command, func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ErrInvalidSecretName, tt.run) requireRejectedAndUnchanged(t, before, vault.ValidateSecretName(tt.rejected), tt.run)
}) })
} }
} }
@@ -283,8 +279,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 --force x", func(c *cli.Instance, version string) error { {"version rm x", func(c *cli.Instance, version string) error {
return c.RemoveVersion(cmd, "x", version, true) return c.RemoveVersion(cmd, "x", version)
}}, }},
{"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)
@@ -297,7 +293,9 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
for _, tt := range commands { for _, tt := range commands {
for _, version := range []string{"", ".", "..", "../../..", "a/b"} { for _, version := range []string{"", ".", "..", "../../..", "a/b"} {
t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) { t.Run(fmt.Sprintf("%s %q", tt.command, version), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ErrVersionNotFound, want := fmt.Errorf("version '%s' %w '%s'",
version, vault.ErrVersionNotFound, "x")
requireRejectedAndUnchanged(t, before, want,
func(c *cli.Instance) error { return tt.run(c, version) }) func(c *cli.Instance) error { return tt.run(c, version) })
}) })
} }
@@ -351,7 +349,7 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
for _, tt := range commands { for _, tt := range commands {
for _, name := range []string{"", ".", "..", "a/b"} { for _, name := range []string{"", ".", "..", "a/b"} {
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) { t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName, requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
func(c *cli.Instance) error { func(c *cli.Instance) error {
c.Mnemonic = mnemonic c.Mnemonic = mnemonic
c.UnlockPassphrase = passphrase c.UnlockPassphrase = passphrase
@@ -363,9 +361,9 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
} }
} }
// TestRemoveVersionRemovesOnlyThatVersion checks that // TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
// `secret version rm --force` with a version that is not the current one // with a version that is not the current one removes that version and
// removes that version and changes nothing else. // changes nothing else.
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) { func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
t.Parallel() t.Parallel()
@@ -391,7 +389,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], true) err = c.RemoveVersion(&cobra.Command{}, "x", versions[1])
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.
+1 -1
View File
@@ -3,11 +3,11 @@ package cli
import ( import (
"os" "os"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"golang.org/x/term" "golang.org/x/term"
"sneak.berlin/go/secret/internal/secret"
) )
// Entry runs the secret CLI and returns the process exit code. It wipes // Entry runs the secret CLI and returns the process exit code. It wipes
+45 -75
View File
@@ -11,11 +11,11 @@ import (
"slices" "slices"
"strings" "strings"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -32,7 +32,13 @@ const (
// Sentinel errors for secret operations // Sentinel errors for secret operations
var ( var (
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit") errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
errSecretFileTooLarge = errors.New(
"secret file too large: exceeds 100MB limit")
errSecretNotFound = errors.New("not found")
errSecretExistsNoForce = errors.New(
"already exists (use --force to overwrite)")
errVaultDoesNotExist = errors.New("does not exist")
errCrossVaultSourceUnqualified = errors.New( errCrossVaultSourceUnqualified = errors.New(
"source must specify vault (e.g., vault:secret) for cross-vault move") "source must specify vault (e.g., vault:secret) for cross-vault move")
errMoveOntoItself = errors.New("cannot be moved onto itself") errMoveOntoItself = errors.New("cannot be moved onto itself")
@@ -199,25 +205,19 @@ 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], force) return cli.RemoveSecret(cmd, args[0], false)
}, },
} }
cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation")
return cmd return cmd
} }
@@ -667,6 +667,10 @@ func (cli *Instance) ImportSecret(
buffers, totalSize, err := readSecretFromReader(file) buffers, totalSize, err := readSecretFromReader(file)
if err != nil { if err != nil {
if errors.Is(err, errSecretTooLarge) {
return errSecretFileTooLarge
}
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err) return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
} }
defer destroyBuffers(buffers) defer destroyBuffers(buffers)
@@ -695,64 +699,29 @@ func (cli *Instance) ImportSecret(
return nil return nil
} }
// RemoveSecret removes a secret and all its versions from the current // RemoveSecret removes a secret from the vault
// vault, after asking the user to confirm unless force is set. func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
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
} }
var found secretToRemove release, err := vault.LockStateDir(cli.fs, cli.stateDir)
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()
err = secret.RemoveDirAtomic(cli.fs, found.dir) // Get current vault
if err != nil {
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) currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil { if err != nil {
return secretToRemove{}, err return err
} }
// Check if secret exists
vaultDir, err := currentVlt.GetDirectory() vaultDir, err := currentVlt.GetDirectory()
if err != nil { if err != nil {
return secretToRemove{}, err return err
} }
encodedName := strings.ReplaceAll(secretName, "/", "%") encodedName := strings.ReplaceAll(secretName, "/", "%")
@@ -760,30 +729,32 @@ func (cli *Instance) findSecretToRemove(
exists, err := afero.DirExists(cli.fs, secretDir) exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil { if err != nil {
return secretToRemove{}, return fmt.Errorf("failed to check if secret exists: %w", err)
fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
return secretToRemove{}, return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
} }
// A secret without a versions directory has no versions, and can // Count versions for information
// still be removed. versionsDir := filepath.Join(secretDir, "versions")
versions, err := afero.ReadDir(cli.fs, filepath.Join(secretDir, "versions")) versionCount := 0
if err != nil && !errors.Is(err, os.ErrNotExist) {
return secretToRemove{}, fmt.Errorf( entries, err := afero.ReadDir(cli.fs, versionsDir)
"failed to count the versions of secret '%s': %w", secretName, err) if err == nil {
versionCount = len(entries)
} }
return secretToRemove{ // Remove the secret directory
dir: secretDir, err = secret.RemoveDirAtomic(cli.fs, secretDir)
versions: len(versions), if err != nil {
question: fmt.Sprintf("Permanently remove secret '%s' and its %d "+ return fmt.Errorf("failed to remove secret: %w", err)
"version(s) from vault '%s'?", }
secretName, len(versions), currentVlt.GetName()),
}, nil cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
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
@@ -897,7 +868,7 @@ func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
} }
if !slices.Contains(vaults, name) { if !slices.Contains(vaults, name) {
return nil, fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound) return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
} }
return vault.NewVault(cli.fs, cli.stateDir, name), nil return vault.NewVault(cli.fs, cli.stateDir, name), nil
@@ -928,7 +899,7 @@ func (cli *Instance) moveSecretWithinVault(
} }
if !exists { if !exists {
return fmt.Errorf("secret '%s' %w", source, vault.ErrSecretNotFound) return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
} }
destEncoded := strings.ReplaceAll(dest, "/", "%") destEncoded := strings.ReplaceAll(dest, "/", "%")
@@ -953,8 +924,7 @@ func (cli *Instance) moveSecretWithinVault(
if exists { if exists {
if !force { if !force {
return fmt.Errorf("secret '%s' %w (use --force to overwrite)", return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
dest, vault.ErrSecretExists)
} }
err = secret.RemoveDirAtomic(cli.fs, destDir) err = secret.RemoveDirAtomic(cli.fs, destDir)
@@ -1019,7 +989,7 @@ func (cli *Instance) moveSecretCrossVault(
exists, err := afero.DirExists(cli.fs, srcSecretDir) exists, err := afero.DirExists(cli.fs, srcSecretDir)
if err != nil || !exists { if err != nil || !exists {
return fmt.Errorf("secret '%s' %w in vault '%s'", return fmt.Errorf("secret '%s' %w in vault '%s'",
srcSecretName, vault.ErrSecretNotFound, srcVault.Name) srcSecretName, errSecretNotFound, srcVault.Name)
} }
// The source is removed after the copy, so a destination that is the // The source is removed after the copy, so a destination that is the
+73 -54
View File
@@ -10,13 +10,13 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"golang.org/x/sys/unix" "golang.org/x/sys/unix"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testVaultName is the vault name used by the size tests. // testVaultName is the vault name used by the size tests.
@@ -71,8 +71,7 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault // Create vault
_, err := vault.CreateVault(fs, testStateDir, testVaultName, _, err := vault.CreateVault(fs, testStateDir, testVaultName, testMnemonicBuffer(t))
testMnemonicBuffer(t), nil)
require.NoError(t, err) require.NoError(t, err)
// Set current vault // Set current vault
@@ -93,8 +92,8 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
} }
// runAddSecretSizeCase adds a secret of the given size through stdin and // runAddSecretSizeCase adds a secret of the given size through stdin and
// verifies the outcome: wantErr, or the secret stored when wantErr is nil. // verifies the outcome.
func runAddSecretSizeCase(t *testing.T, size int, wantErr error) { func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
t.Helper() t.Helper()
skipIfLockedMemoryTooLow(t, size) skipIfLockedMemoryTooLow(t, size)
@@ -128,8 +127,9 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr error) {
secretName := fmt.Sprintf("test-secret-%d", size) secretName := fmt.Sprintf("test-secret-%d", size)
err = cli.AddSecret(secretName, false) err = cli.AddSecret(secretName, false)
if wantErr != nil { if wantErr {
require.ErrorIs(t, err, wantErr) require.Error(t, err)
assert.Contains(t, err.Error(), errMsg)
return return
} }
@@ -147,8 +147,8 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr error) {
} }
// runImportSecretSizeCase imports a secret file of the given size and // runImportSecretSizeCase imports a secret file of the given size and
// verifies the outcome: wantErr, or the secret stored when wantErr is nil. // verifies the outcome.
func runImportSecretSizeCase(t *testing.T, size int, wantErr error) { func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
t.Helper() t.Helper()
skipIfLockedMemoryTooLow(t, size) skipIfLockedMemoryTooLow(t, size)
@@ -180,8 +180,9 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr error) {
secretName := fmt.Sprintf("imported-secret-%d", size) secretName := fmt.Sprintf("imported-secret-%d", size)
err = cli.ImportSecret(cmd, secretName, testFile, false) err = cli.ImportSecret(cmd, secretName, testFile, false)
if wantErr != nil { if wantErr {
require.ErrorIs(t, err, wantErr) require.Error(t, err)
assert.Contains(t, err.Error(), errMsg)
return return
} }
@@ -203,48 +204,57 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr error) {
//nolint:paralleltest // together the subtests lock more than the memlock limit //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
size int size int
wantErr error shouldError bool
errorMsg string
}{ }{
{ {
name: "1KB secret", name: "1KB secret",
size: 1024, size: 1024,
shouldError: false,
}, },
{ {
name: "10KB secret", name: "10KB secret",
size: 10 * 1024, size: 10 * 1024,
shouldError: false,
}, },
{ {
name: "100KB secret", name: "100KB secret",
size: 100 * 1024, size: 100 * 1024,
shouldError: false,
}, },
{ {
name: "1MB secret", name: "1MB secret",
size: 1024 * 1024, size: 1024 * 1024,
shouldError: false,
}, },
{ {
name: "10MB secret", name: "10MB secret",
size: 10 * 1024 * 1024, size: 10 * 1024 * 1024,
shouldError: false,
}, },
{ {
name: "99MB secret", name: "99MB secret",
size: 99 * 1024 * 1024, size: 99 * 1024 * 1024,
shouldError: false,
}, },
{ {
name: "100MB secret minus 1 byte", name: "100MB secret minus 1 byte",
size: 100*1024*1024 - 1, size: 100*1024*1024 - 1,
shouldError: false,
}, },
{ {
name: "101MB secret - should fail", name: "101MB secret - should fail",
size: 101 * 1024 * 1024, size: 101 * 1024 * 1024,
wantErr: errSecretTooLarge, shouldError: true,
errorMsg: "secret too large: exceeds 100MB limit",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
runAddSecretSizeCase(t, tt.size, tt.wantErr) runAddSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
}) })
} }
} }
@@ -254,48 +264,57 @@ func TestAddSecretVariousSizes(t *testing.T) {
//nolint:paralleltest // together the subtests lock more than the memlock limit //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
size int size int
wantErr error shouldError bool
errorMsg string
}{ }{
{ {
name: "1KB file", name: "1KB file",
size: 1024, size: 1024,
shouldError: false,
}, },
{ {
name: "10KB file", name: "10KB file",
size: 10 * 1024, size: 10 * 1024,
shouldError: false,
}, },
{ {
name: "100KB file", name: "100KB file",
size: 100 * 1024, size: 100 * 1024,
shouldError: false,
}, },
{ {
name: "1MB file", name: "1MB file",
size: 1024 * 1024, size: 1024 * 1024,
shouldError: false,
}, },
{ {
name: "10MB file", name: "10MB file",
size: 10 * 1024 * 1024, size: 10 * 1024 * 1024,
shouldError: false,
}, },
{ {
name: "99MB file", name: "99MB file",
size: 99 * 1024 * 1024, size: 99 * 1024 * 1024,
shouldError: false,
}, },
{ {
name: "100MB file", name: "100MB file",
size: 100 * 1024 * 1024, size: 100 * 1024 * 1024,
shouldError: false,
}, },
{ {
name: "101MB file - should fail", name: "101MB file - should fail",
size: 101 * 1024 * 1024, size: 101 * 1024 * 1024,
wantErr: errSecretTooLarge, shouldError: true,
errorMsg: "secret file too large: exceeds 100MB limit",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
runImportSecretSizeCase(t, tt.size, tt.wantErr) runImportSecretSizeCase(t, tt.size, tt.shouldError, tt.errorMsg)
}) })
} }
} }
+1 -1
View File
@@ -7,9 +7,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
// TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret // TestGetCommandOutputsToStdout tests that 'secret get' outputs the secret
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"os" "os"
"strings" "strings"
"sneak.berlin/go/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
) )
// ExecuteCommandInProcess executes a CLI command in-process for testing // ExecuteCommandInProcess executes a CLI command in-process for testing
+1 -1
View File
@@ -3,9 +3,9 @@ package cli_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
) )
//nolint:paralleltest // executes the CLI in-process against shared state //nolint:paralleltest // executes the CLI in-process against shared state
-374
View File
@@ -1,374 +0,0 @@
// 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"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
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: stdin is not a terminal (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())
}
+204 -104
View File
@@ -6,7 +6,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"log" "log"
"maps"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -15,10 +14,10 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// Unlocker type names and platform identifiers shared across the CLI // Unlocker type names and platform identifiers shared across the CLI
@@ -39,10 +38,16 @@ var (
errInvalidUnlockerType = errors.New("invalid unlocker type") errInvalidUnlockerType = errors.New("invalid unlocker type")
errKeyIDOnlyForPGP = errors.New( errKeyIDOnlyForPGP = errors.New(
"--keyid flag is only valid for PGP unlockers") "--keyid flag is only valid for PGP unlockers")
errKeychainMacOSOnly = errors.New(
"keychain unlockers are only supported on macOS")
errSecureEnclaveMacOSOnly = errors.New(
"secure enclave unlockers are only supported on macOS")
// errGPGKeyAlreadyUnlocker carries only the message tail; the caller // errGPGKeyAlreadyUnlocker carries only the message tail; the caller
// composes "GPG key <id> is already added as an unlocker". // composes "GPG key <id> is already added as an unlocker".
errGPGKeyAlreadyUnlocker = errors.New( errGPGKeyAlreadyUnlocker = errors.New(
"is already added as an unlocker") "is already added as an unlocker")
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
@@ -262,11 +267,10 @@ 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. Asks for ` + Long: `Remove an unlocker from the current vault. Cannot remove ` +
`confirmation first, saying whether it is the vault's last ` + `the last unlocker if the vault has secrets unless --force is ` +
`unlocker; when stdin is not a terminal, fails unless --force ` + `used. Warning: Without unlockers and without your mnemonic, ` +
`is given. Warning: Without unlockers and without your ` + `vault data will be permanently inaccessible.`,
`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 {
@@ -282,7 +286,7 @@ func newUnlockerRemoveCmd() *cobra.Command {
} }
cmd.Flags().BoolP("force", "f", false, cmd.Flags().BoolP("force", "f", false,
"Remove without asking for confirmation, even the last unlocker") "Force removal of last unlocker even if vault has secrets")
return cmd return cmd
} }
@@ -309,8 +313,91 @@ func newUnlockerSelectCmd() *cobra.Command {
} }
} }
// UnlockersList lists unlockers in the current vault, each under its ID, // unlockerIDFromDir constructs an unlocker of the given metadata type
// the name of its directory in unlockers.d // rooted at unlockerDir and returns its ID. Returns "" for unknown types
// 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)
@@ -326,23 +413,58 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
currentUnlockerID = currentUnlocker.GetID() currentUnlockerID = currentUnlocker.GetID()
} }
unlockerMetadata, err := vlt.ListUnlockers() // Get the metadata first
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 _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) { for _, metadata := range unlockerMetadataList {
metadata := unlockerMetadata[unlockerID] // Create unlocker instance to get the proper ID
vaultDir, err := vlt.GetDirectory()
if err != nil {
secret.Warn("Could not get vault directory while listing unlockers",
"error", err)
unlockers = append(unlockers, UnlockerInfo{ continue
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: unlockerID == currentUnlockerID, IsCurrent: properID == currentUnlockerID,
}) }
unlockers = append(unlockers, unlockerInfo)
} }
if jsonOutput { if jsonOutput {
@@ -434,7 +556,7 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
} }
return fmt.Errorf("%w: %s (supported: %s)", return fmt.Errorf("%w: %s (supported: %s)",
errInvalidUnlockerType, unlockerType, supportedTypes) errUnsupportedUnlockerType, unlockerType, supportedTypes)
} }
} }
@@ -469,7 +591,7 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) 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 err return fmt.Errorf("failed to read passphrase: %w", err)
} }
defer passphraseBuffer.Destroy() defer passphraseBuffer.Destroy()
} }
@@ -489,6 +611,10 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
// addKeychainUnlocker creates a macOS Keychain unlocker in the current vault // addKeychainUnlocker creates a macOS Keychain unlocker in the current vault
func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error { func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
if runtime.GOOS != platformDarwin {
return errKeychainMacOSOnly
}
keychainUnlocker, err := secret.CreateKeychainUnlocker( keychainUnlocker, err := secret.CreateKeychainUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
@@ -516,6 +642,10 @@ func (cli *Instance) addKeychainUnlocker(cmd *cobra.Command) error {
// addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the // addSecureEnclaveUnlocker creates a Secure Enclave unlocker in the
// current vault // current vault
func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error { func (cli *Instance) addSecureEnclaveUnlocker(cmd *cobra.Command) error {
if runtime.GOOS != platformDarwin {
return errSecureEnclaveMacOSOnly
}
seUnlocker, err := secret.CreateSecureEnclaveUnlocker( seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase) cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
if err != nil { if err != nil {
@@ -567,7 +697,9 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
} }
// Check if this GPG key is already added // Check if this GPG key is already added
exists, err := cli.pgpUnlockerExists(vlt, fingerprint) expectedID := "pgp-" + 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",
@@ -594,85 +726,55 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
return nil return nil
} }
// UnlockersRemove removes an unlocker from the current vault, after asking // UnlockersRemove removes an unlocker, holding the state directory lock
// the user to confirm unless force is set. // while removeUnlocker runs
func (cli *Instance) UnlockersRemove( func (cli *Instance) UnlockersRemove(
unlockerID string, force bool, cmd *cobra.Command, unlockerID string, force bool, cmd *cobra.Command,
) error { ) error {
var found unlockerToRemove release, err := vault.LockStateDir(cli.fs, cli.stateDir)
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, found, cmd) return cli.removeUnlocker(unlockerID, force, cmd)
} }
// unlockerToRemove is what removing an unlocker removes, as // removeUnlocker removes an unlocker with safety checks
// findUnlockerToRemove found it. func (cli *Instance) removeUnlocker(
type unlockerToRemove struct { unlockerID string, force bool, cmd *cobra.Command,
vlt *vault.Vault ) error {
// last is set when the unlocker counts as the vault's last one, and // Get current vault
// 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 unlockerToRemove{}, err return err
}
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 // Get list of unlockers. It leaves out a directory whose metadata file
// is missing or cannot be checked for, read or parsed. // 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 unlockerToRemove{}, return fmt.Errorf("failed to list unlockers: %w", err)
fmt.Errorf("failed to list unlockers: %w", err)
} }
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
return unlockerToRemove{}, return fmt.Errorf("failed to get vault directory: %w", err)
fmt.Errorf("failed to get vault directory: %w", err)
} }
unlockersDir := filepath.Join(vaultDir, "unlockers.d") unlockersDir := filepath.Join(vaultDir, "unlockers.d")
found := unlockerToRemove{ // Check if we're removing the last unlocker
vlt: vlt, removingLast := false
question: fmt.Sprintf("Permanently remove unlocker '%s' from vault "+
"'%s'? It is not the vault's last unlocker.",
unlockerID, vlt.GetName()),
}
if len(unlockers) == 1 { if len(unlockers) == 1 {
_, found.last = unlockers[unlockerID] lastID, err := findUnlockerIDByMetadata(
cli.fs, unlockersDir, unlockers[0], true)
if err != nil {
return err
}
removingLast = lastID == unlockerID
} }
// unlockerID may instead name a directory left out of the list. If its // unlockerID may instead name a directory left out of the list. If its
@@ -681,36 +783,34 @@ func (cli *Instance) findUnlockerToRemove(
// for or read, the unlocker may be the only working one, so removing it // for or read, the unlocker may be the only working one, so removing it
// counts as removing the last unlocker. // counts as removing the last unlocker.
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) { if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
found.last = true removingLast = true
} }
if found.last { if removingLast {
found.secrets, err = vlt.NumSecrets() // Check if vault has secrets
numSecrets, err := vlt.NumSecrets()
if err != nil { if err != nil {
return unlockerToRemove{}, return fmt.Errorf("failed to count secrets: %w", err)
fmt.Errorf("failed to count secrets: %w", err)
} }
found.question = fmt.Sprintf("Permanently remove unlocker '%s', "+ if numSecrets > 0 && !force {
"the last unlocker of vault '%s', which holds %d secret(s)? "+ cmd.Println("ERROR: Cannot remove the last unlocker when the " +
"Without an unlocker the vault opens only with its mnemonic.", "vault contains secrets.")
unlockerID, vlt.GetName(), found.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!")
}
} }
return found, nil // Remove the unlocker
} err = vlt.RemoveUnlocker(unlockerID)
// 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
} }
@@ -751,16 +851,16 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
return vlt.SelectUnlocker(unlockerID) return vlt.SelectUnlocker(unlockerID)
} }
// pgpUnlockerExists reports whether the vault already has a PGP unlocker // checkUnlockerExists reports whether the vault already has an unlocker
// for the GPG key with the given fingerprint. It returns an error, and no // with the given ID. It returns an error, and no answer, when unlockers.d
// answer, when unlockers.d or an unlocker's metadata file cannot be read; // or an unlocker's metadata file cannot be read; the caller must then not
// the caller must then not create the unlocker. It reads unlockers.d itself // create the unlocker. It reads unlockers.d itself because
// because vault.ListUnlockers skips an unlocker it cannot read, which suits // 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) pgpUnlockerExists( func (cli *Instance) checkUnlockerExists(
vlt *vault.Vault, fingerprint string, vlt *vault.Vault, unlockerID string,
) (bool, error) { ) (bool, error) {
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
@@ -799,14 +899,14 @@ func (cli *Instance) pgpUnlockerExists(
) )
} }
var metadata secret.PGPUnlockerMetadata var metadata secret.UnlockerMetadata
err = json.Unmarshal(metadataBytes, &metadata) err = json.Unmarshal(metadataBytes, &metadata)
if err != nil { if err != nil {
continue continue
} }
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint { if unlockerIDFromDir(cli.fs, unlockerDir, metadata, true) == unlockerID {
return true, nil return true, nil
} }
} }
+3 -4
View File
@@ -5,12 +5,11 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has. // unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
@@ -48,7 +47,7 @@ func TestAddPGPUnlocker(t *testing.T) {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName, vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
err = vlt.AddSecret(addTestSecretName, err = vlt.AddSecret(addTestSecretName,
@@ -99,7 +98,7 @@ func TestAddPGPUnlockerUnknownKey(t *testing.T) {
err := instance.addPGPUnlocker(cmd) err := instance.addPGPUnlocker(cmd)
require.ErrorIs(t, err, secret.ErrGPGKeyNotFound) require.ErrorContains(t, err, "failed to resolve GPG key fingerprint")
assertDirEntries(t, base, assertDirEntries(t, base,
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName), filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
listTestUnlockerDirOne) listTestUnlockerDirOne)
+37 -61
View File
@@ -4,23 +4,21 @@
// by its ID. These tests give the first unlocker, which sorts before the // 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 // 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 // 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 // directory name, which `secret unlocker list` names in its warning. A
// one with no metadata file. A last test checks that an unlocker whose // last test checks that an unlocker whose metadata file cannot be read is
// metadata file cannot be read counts as the last unlocker when it is // removed by its directory name only as the last unlocker is.
// removed by its directory name.
//nolint:testpackage // white-box test of unexported internals //nolint:testpackage // white-box test of unexported internals
package cli package cli
import ( import (
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"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"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
) )
// newCorruptUnlockerVault returns the two-unlocker test vault with the // newCorruptUnlockerVault returns the two-unlocker test vault with the
@@ -46,7 +44,7 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
fs := newCorruptUnlockerVault(t) fs := newCorruptUnlockerVault(t)
instance, _ := newTestInstance(fs) instance, _ := newTestInstance(fs)
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo)) require.NoError(t, instance.UnlockerSelect("pgp-"+listTestGPGKeyID+"B"))
current, err := afero.ReadFile(fs, current, err := afero.ReadFile(fs,
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker")) filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
@@ -58,27 +56,37 @@ func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
} }
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker // TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
// counts as the vault's last one, since the corrupt unlocker cannot unlock // can be removed, unless the vault holds secrets: the corrupt unlocker
// the vault, and that the corrupt one, removed by its directory name, does // cannot unlock the vault, so the second is its last. The corrupt one can
// not. Either is removed once the user confirms. // be removed by its directory name without --force even then.
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) { func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
unlockerID string unlockerID string
wantLast bool withSecret bool
wantErr error
wantEntries []string wantEntries []string
}{ }{
{ {
name: "the other unlocker", name: "the other unlocker",
unlockerID: listTestUnlockerDirTwo, unlockerID: "pgp-" + listTestGPGKeyID + "B",
wantLast: true,
wantEntries: []string{listTestUnlockerDirOne}, wantEntries: []string{listTestUnlockerDirOne},
}, },
{
name: "the other unlocker, the last one, with secrets",
unlockerID: "pgp-" + listTestGPGKeyID + "B",
withSecret: true,
wantErr: errLastUnlocker,
wantEntries: []string{
listTestUnlockerDirOne, listTestUnlockerDirTwo,
},
},
{ {
name: "the corrupt unlocker by its directory name", name: "the corrupt unlocker by its directory name",
unlockerID: listTestUnlockerDirOne, unlockerID: listTestUnlockerDirOne,
withSecret: true,
wantEntries: []string{listTestUnlockerDirTwo}, wantEntries: []string{listTestUnlockerDirTwo},
}, },
} }
@@ -88,16 +96,14 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
t.Parallel() t.Parallel()
fs := newCorruptUnlockerVault(t) fs := newCorruptUnlockerVault(t)
writeTestSecret(t, fs, testVaultDir(listTestVaultName)) if tt.withSecret {
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
}
instance, cmd := newTestInstance(fs) instance, cmd := newTestInstance(fs)
found, err := instance.findUnlockerToRemove(tt.unlockerID) err := instance.UnlockersRemove(tt.unlockerID, false, cmd)
require.NoError(t, err) require.ErrorIs(t, err, tt.wantErr)
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, assertDirEntries(t, fs,
filepath.Join(testVaultDir(listTestVaultName), filepath.Join(testVaultDir(listTestVaultName),
@@ -107,42 +113,13 @@ func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
} }
} }
// TestUnlockerRemoveWithoutMetadata asserts that a partial unlocker // TestUnlockerRemoveWithUnreadableMetadata asserts that removing the only
// directory, one with no metadata file, removed by its directory name from // unlocker of a vault with secrets by its directory name, when its
// a vault with secrets, does not count as the vault's last unlocker, since // metadata file cannot be checked for or read, is refused without --force:
// it cannot unlock the vault, so the question says it is not. It is // listing leaves it out, but it may still be the vault's only working
// removed once the user confirms. // unlocker. With --force it is removed. The state directory lock refuses
func TestUnlockerRemoveWithoutMetadata(t *testing.T) { // the failing filesystem, so the test calls removeUnlocker, which
t.Parallel() // UnlockersRemove runs once it holds the lock.
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) { func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
t.Parallel() t.Parallel()
@@ -178,13 +155,12 @@ func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
instance, cmd := newTestInstance(tt.wrap(base)) instance, cmd := newTestInstance(tt.wrap(base))
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne) err := instance.removeUnlocker(listTestUnlockerDirOne, false, cmd)
require.NoError(t, err) require.ErrorIs(t, err, errLastUnlocker)
assert.True(t, found.last) assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
assert.Contains(t, found.question, "the last unlocker")
require.NoError(t, require.NoError(t,
instance.removeUnlocker(listTestUnlockerDirOne, found, cmd)) instance.removeUnlocker(listTestUnlockerDirOne, true, cmd))
assertDirEntries(t, base, unlockersDir) assertDirEntries(t, base, unlockersDir)
}) })
} }
-79
View File
@@ -1,79 +0,0 @@
//nolint:testpackage // white-box test of unexported internals
package cli
import (
"encoding/json"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
// 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)
}
+81 -26
View File
@@ -1,13 +1,25 @@
// Unlocker List Tests // Unlocker List Tests
// //
// Tests for `secret unlocker list` behavior when an unlocker's metadata // Tests for `secret unlocker list` behavior when the unlockers.d directory,
// cannot be read or used: // or an unlocker's metadata in it, cannot be read while the listing is
// 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
@@ -21,11 +33,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
@@ -36,16 +48,18 @@ 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, with a letter appended, // listTestGPGKeyID is the GPG key ID recorded in the readable PGP
// in the PGP unlockers' metadata. // unlocker's metadata. The unlocker's real ID is derived from it, and
// 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, and so the unlockers' IDs. // directory names under unlockers.d.
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 holding the unlockers. // listTestUnlockersDirName is the directory the listing rescans to
// 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.
@@ -60,16 +74,25 @@ 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 fails every open of unlockers.d, as when the // unlockersDirFailFs makes unlockers.d unreadable once it has been opened
// directory cannot be read. // successfully openBudget times. This reproduces the directory becoming
// 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 {
return nil, errUnlockersDirUnreadable f.opens++
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
@@ -119,8 +142,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 named dirName, with // writePGPUnlocker writes a PGP unlocker directory with metadata that
// metadata recording the GPG key ID keyID. // yields the real ID "pgp-<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,
@@ -201,6 +224,44 @@ 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) {
@@ -211,21 +272,20 @@ 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, listTestUnlockerDirOne, unlockers[0].ID) assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID) assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", 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, and // leaves that unlocker out; PGP metadata without a usable GPG key ID lists
// metadata of an unknown type, are still listed, under the directory name // it as "pgp-unknown". The healthy unlocker is listed with its real ID.
// 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 := listTestUnlockerDirOne healthyID := "pgp-" + listTestGPGKeyID + "A"
tests := []struct { tests := []struct {
name string name string
@@ -240,17 +300,12 @@ 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, listTestUnlockerDirTwo}, wantIDs: []string{healthyID, "pgp-unknown"},
}, },
{ {
name: "GPG key ID missing", name: "GPG key ID missing",
metadata: `{"type": "pgp"}`, metadata: `{"type": "pgp"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo}, wantIDs: []string{healthyID, "pgp-unknown"},
},
{
name: "unknown type",
metadata: `{"type": "unknown"}`,
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
}, },
} }
@@ -316,7 +371,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, listTestUnlockerDirTwo, unlockers[0].ID, assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
"the listed row must carry the real unlocker ID") "the listed row must carry the real unlocker ID")
}) })
} }
+10 -37
View File
@@ -2,18 +2,15 @@
// //
// 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?), removing a secret (how many versions does it // vault hold secrets?), and importing a mnemonic (does the vault already
// have?), and importing a mnemonic (does the vault already have a // have a long-term key?) each look at the vault on disk before acting.
// 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 addPGPUnlocker // function each command runs once it holds the lock, such as removeVault
// for UnlockersAdd, or, for a removal, the function that makes its checks, // for RemoveVault.
// 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
@@ -28,11 +25,11 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
@@ -288,9 +285,10 @@ func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
base := newListTestVault(t, 1) base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path}) instance, cmd := newTestInstance(&statFailFs{Fs: base, path: path})
_, err := instance.findUnlockerToRemove(listTestUnlockerDirOne) err := instance.removeUnlocker(
"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)
@@ -334,9 +332,9 @@ func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
base := newListTestVault(t, 1) base := newListTestVault(t, 1)
writeTestSecret(t, base, vaultDir) writeTestSecret(t, base, vaultDir)
instance, _ := newTestInstance(tt.failFs(base)) instance, cmd := newTestInstance(tt.failFs(base))
_, err := instance.findVaultToRemove(unreadableTestOtherVault) err := instance.removeVault(cmd, unreadableTestOtherVault, false)
require.ErrorIs(t, err, tt.wantErr) require.ErrorIs(t, err, tt.wantErr)
@@ -347,31 +345,6 @@ 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.
+2 -2
View File
@@ -4,10 +4,10 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/cli"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/cli"
"sneak.berlin/go/secret/internal/secret"
) )
// usageHeading starts the usage text cobra prints after an error. // usageHeading starts the usage text cobra prints after an error.
+96 -102
View File
@@ -10,19 +10,20 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Sentinel errors for vault operations // Sentinel errors for vault operations
var ( var (
errMnemonicEmpty = errors.New("mnemonic cannot be empty") errMnemonicEmpty = errors.New("mnemonic cannot be empty")
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase") errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
errInvalidMnemonic = errors.New("invalid BIP39 mnemonic")
errVaultHasLongTermKey = errors.New( errVaultHasLongTermKey = errors.New(
"already has a long-term key configured") "already has a long-term key configured")
errMnemonicEnvNotSet = errors.New( errMnemonicEnvNotSet = errors.New(
@@ -30,6 +31,8 @@ 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 {
@@ -153,12 +156,9 @@ 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 and all its secrets. Asks for ` + Long: `Remove a vault. Requires --force if the vault contains ` +
`confirmation first, naming how many secrets the vault ` + `secrets. Will automatically switch to another vault if ` +
`holds; when stdin is not a terminal, fails unless --force ` + `removing the currently selected one.`,
`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 {
@@ -173,8 +173,7 @@ func newVaultRemoveCmd() *cobra.Command {
}, },
} }
cmd.Flags().BoolP("force", "f", false, cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
"Remove without asking for confirmation, even a vault that contains secrets")
return cmd return cmd
} }
@@ -249,7 +248,7 @@ func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error)
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return nil, nil, err return nil, nil, fmt.Errorf("failed to read passphrase: %w", err)
} }
return passphraseBuffer, passphraseBuffer.Destroy, nil return passphraseBuffer, passphraseBuffer.Destroy, nil
@@ -292,26 +291,40 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
} }
defer cleanupPassphrase() defer cleanupPassphrase()
// Create the vault with its passphrase unlocker // Create the vault - it will handle key derivation internally
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name, mnemonic)
mnemonic, passphraseBuffer)
if err != nil { if err != nil {
return err return err
} }
ltIdentity, err := vlt.GetOrDeriveLongTermKey() // Get the vault metadata to retrieve the derivation index
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 get long-term key: %w", err) return fmt.Errorf("failed to load vault metadata: %w", err)
} }
unlocker, err := vlt.GetCurrentUnlocker() // Derive the long-term key using the same index that CreateVault used
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to derive long-term key from mnemonic: %w", 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", unlocker.GetID()) cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
return nil return nil
} }
@@ -352,7 +365,7 @@ func (cli *Instance) vaultImportPreflight(
if !exists { if !exists {
return "", "", "", fmt.Errorf("vault '%s' %w", return "", "", "", fmt.Errorf("vault '%s' %w",
vaultName, vault.ErrVaultNotFound) vaultName, errVaultDoesNotExist)
} }
// Check if vault already has a public key // Check if vault already has a public key
@@ -380,7 +393,7 @@ func (cli *Instance) vaultImportPreflight(
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords)) secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
if !bip39.IsMnemonicValid(mnemonic) { if !bip39.IsMnemonicValid(mnemonic) {
return "", "", "", errInvalidMnemonicPhrase return "", "", "", errInvalidMnemonic
} }
return vaultDir, pubKeyPath, mnemonic, nil return vaultDir, pubKeyPath, mnemonic, nil
@@ -524,27 +537,27 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
return nil return nil
} }
// countVaultSecrets returns the number of secrets in the vault directory // vaultHasSecrets reports whether the vault directory contains any secrets
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) { func (cli *Instance) vaultHasSecrets(vaultDir string) (bool, 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 0, fmt.Errorf("failed to check secrets directory %s: %w", return false, fmt.Errorf("failed to check secrets directory %s: %w",
secretsDir, err) secretsDir, err)
} }
if !exists { if !exists {
return 0, nil return false, nil
} }
entries, err := afero.ReadDir(cli.fs, secretsDir) entries, err := afero.ReadDir(cli.fs, secretsDir)
if err != nil { if err != nil {
return 0, fmt.Errorf("failed to read secrets directory %s: %w", return false, fmt.Errorf("failed to read secrets directory %s: %w",
secretsDir, err) secretsDir, err)
} }
return len(entries), nil return len(entries) > 0, nil
} }
// switchAwayFromVault selects another vault as current before removal // switchAwayFromVault selects another vault as current before removal
@@ -573,107 +586,88 @@ func (cli *Instance) switchAwayFromVault(
return nil return nil
} }
// RemoveVault removes a vault and all its secrets, after asking the user // RemoveVault removes a vault, holding the state directory lock while
// to confirm unless force is set. // removeVault runs
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error { func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
err := vault.ValidateVaultName(name) err := vault.ValidateVaultName(name)
if err != nil { if err != nil {
return err return err
} }
var found vaultToRemove release, err := vault.LockStateDir(cli.fs, cli.stateDir)
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 found.isCurrent { if isCurrentVault {
err = cli.switchAwayFromVault(cmd, found.vaults, name) err = cli.switchAwayFromVault(cmd, 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, found.dir) err = secret.RemoveDirAtomic(cli.fs, vaultDir)
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 found.secrets > 0 { if hasSecrets {
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, vault.ErrVaultNotFound)
}
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
}
+31 -67
View File
@@ -11,10 +11,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -23,6 +23,7 @@ const (
// Sentinel errors for version operations // Sentinel errors for version operations
var ( var (
errVersionNotFound = errors.New("not found for secret")
errCannotRemoveCurrentVersion = errors.New("promote another version first") errCannotRemoveCurrentVersion = errors.New("promote another version first")
) )
@@ -88,8 +89,7 @@ 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. Asks for confirmation first; when stdin " + "current version.",
"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,
@@ -102,15 +102,10 @@ 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 {
force, _ := cmd.Flags().GetBool("force") return cli.RemoveVersion(cmd, args[0], args[1])
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
@@ -155,7 +150,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
if !exists { if !exists {
secret.Debug("Secret not found", "secret_name", secretName) secret.Debug("Secret not found", "secret_name", secretName)
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound) return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
} }
// List all versions // List all versions
@@ -288,7 +283,7 @@ func (cli *Instance) PromoteVersion(
if !exists { if !exists {
return fmt.Errorf("version '%s' %w '%s'", return fmt.Errorf("version '%s' %w '%s'",
version, vault.ErrVersionNotFound, secretName) version, errVersionNotFound, secretName)
} }
// Update the current symlink using the proper function // Update the current symlink using the proper function
@@ -302,62 +297,30 @@ func (cli *Instance) PromoteVersion(
return nil return nil
} }
// RemoveVersion removes a specific version of a secret, after asking the // RemoveVersion removes a specific version of a secret
// user to confirm unless force is set.
func (cli *Instance) RemoveVersion( func (cli *Instance) RemoveVersion(
cmd *cobra.Command, secretName string, version string, force bool, cmd *cobra.Command, secretName string, version string,
) error { ) error {
err := vault.ValidateSecretName(secretName) err := vault.ValidateSecretName(secretName)
if err != nil { if err != nil {
return err return err
} }
var found versionToRemove release, err := vault.LockStateDir(cli.fs, cli.stateDir)
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()
err = secret.RemoveDirAtomic(cli.fs, found.dir) // Get current vault
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 versionToRemove{}, err return err
} }
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
if err != nil { if err != nil {
return versionToRemove{}, err return err
} }
// Get the encoded secret name // Get the encoded secret name
@@ -367,44 +330,45 @@ func (cli *Instance) findVersionToRemove(
// 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 versionToRemove{}, return fmt.Errorf("failed to check if secret exists: %w", err)
fmt.Errorf("failed to check if secret exists: %w", err)
} }
if !exists { if !exists {
return versionToRemove{}, return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
fmt.Errorf("secret '%s' %w", secretName, vault.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 versionToRemove{}, return fmt.Errorf("failed to check if version exists: %w", err)
fmt.Errorf("failed to check if version exists: %w", err)
} }
if !exists { if !exists {
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'", return fmt.Errorf("version '%s' %w '%s'",
version, vault.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 versionToRemove{}, return fmt.Errorf("failed to get current version: %w", err)
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 versionToRemove{}, fmt.Errorf( return fmt.Errorf("cannot remove the current version '%s'; %w",
"cannot remove the current version '%s'; %w",
version, errCannotRemoveCurrentVersion) version, errCannotRemoveCurrentVersion)
} }
return versionToRemove{ // Remove the version directory
dir: filepath.Join(secretDir, "versions", version), versionDir := filepath.Join(secretDir, "versions", version)
question: fmt.Sprintf("Permanently remove version %s of secret "+
"'%s' from vault '%s'?", version, secretName, vlt.GetName()), err = secret.RemoveDirAtomic(cli.fs, versionDir)
}, nil 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
} }
+8 -7
View File
@@ -26,13 +26,13 @@ import (
"time" "time"
"unicode/utf8" "unicode/utf8"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
@@ -73,8 +73,7 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
t.Helper() t.Helper()
// Create vault // Create vault
vlt, err := vault.CreateVault(fs, testStateDir, "default", vlt, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t))
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
@@ -171,7 +170,8 @@ func TestListVersionsNonExistentSecret(t *testing.T) {
// Try to list versions of non-existent secret // Try to list versions of non-existent secret
err := cli.ListVersions(cmd, "nonexistent/secret") err := cli.ListVersions(cmd, "nonexistent/secret")
require.ErrorIs(t, err, vault.ErrSecretNotFound) require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
} }
func TestPromoteVersionCommand(t *testing.T) { func TestPromoteVersionCommand(t *testing.T) {
@@ -265,7 +265,8 @@ func TestPromoteNonExistentVersion(t *testing.T) {
// Try to promote non-existent version // Try to promote non-existent version
err = cli.PromoteVersion(cmd, "test/secret", "20991231.999") err = cli.PromoteVersion(cmd, "test/secret", "20991231.999")
require.ErrorIs(t, err, vault.ErrVersionNotFound) require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
} }
func TestGetSecretWithVersion(t *testing.T) { func TestGetSecretWithVersion(t *testing.T) {
+5 -24
View File
@@ -15,7 +15,6 @@ package macse
import "C" import "C"
import ( import (
"errors"
"fmt" "fmt"
"unsafe" "unsafe"
) )
@@ -40,9 +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 // Returns the uncompressed public key bytes (65 bytes) and the identity hash
// (for deletion). If getting the public key fails, CreateKey deletes the key // (for deletion).
// 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
@@ -50,6 +50,7 @@ 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,29 +58,9 @@ 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]))
} }
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 //nolint:nlreturn // CGo result extraction
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
h := C.GoString(&hashBuf[0])
return pk, h, nil return pk, h, nil
} }
+4 -14
View File
@@ -5,30 +5,20 @@
#include <stdint.h> #include <stdint.h>
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and // se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
// 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
+35 -50
View File
@@ -47,6 +47,7 @@ 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 {
@@ -86,56 +87,7 @@ int se_create_key(const char *label,
return -1; return -1;
} }
// Get the identity hash, which deleting the key needs, by parsing // Retrieve the public key from the created identity
// 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;
@@ -174,6 +126,39 @@ int se_copy_public_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;
} }
} }
+2 -41
View File
@@ -5,15 +5,10 @@ import (
"fmt" "fmt"
"os" "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
@@ -22,7 +17,7 @@ const tempNamePart = ".tmp-"
// 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)+tempNamePart+"*") "."+filepath.Base(path)+".tmp-*")
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)
} }
@@ -59,7 +54,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)), tempNamePart) dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
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)
@@ -68,40 +63,6 @@ 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 // 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 // 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 // neither a failure nor a crash leaves dir half-written; on a failure the
+7 -47
View File
@@ -8,13 +8,12 @@ import (
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/macse"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
var errInjected = errors.New("injected failure") var errInjected = errors.New("injected failure")
@@ -236,7 +235,7 @@ func newVaultWithSecret(
) *vault.Vault { ) *vault.Vault {
t.Helper() t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil) vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value)) buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -353,7 +352,7 @@ func TestLongestNames(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
name := strings.Repeat("a", longestName) name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil) vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long")) value := memguard.NewBufferFromBytes([]byte("long"))
@@ -647,7 +646,7 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from // No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil) _, err := vault.CreateVault(base, testVaultStateDir, testVaultName, 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 {
@@ -679,7 +678,7 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName, vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
vaultDir, err := vlt.GetDirectory() vaultDir, err := vlt.GetDirectory()
@@ -728,7 +727,7 @@ func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
base, stateDir := tfs.open(t) base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName, vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
ltIdentity, err := vlt.GetOrDeriveLongTermKey() ltIdentity, err := vlt.GetOrDeriveLongTermKey()
@@ -900,42 +899,3 @@ func TestWriteDirRefusesExistingDir(t *testing.T) {
}) })
} }
} }
// 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()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
require.NoError(t, err)
// The unlocker's directory is named se-<label of its Secure Enclave key>
var seKeyLabel string
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-")
return errInjected
}
return nil
}}
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
nil)
if seKeyLabel == "" {
t.Skipf("the add failed before moving the unlocker into place: %v", err)
}
require.ErrorIs(t, err, errInjected)
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
assert.Error(t, err, "Secure Enclave key left behind")
}
+19 -57
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"os" "os"
"syscall" "syscall"
"unsafe"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -17,17 +16,16 @@ import (
var ( var (
errNilPassphraseBuffer = errors.New("passphrase buffer is nil") errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
errStdinNotTerminal = errors.New( errStdinNotTerminal = errors.New(
"stdin is not a terminal (piped input or script)") "cannot read passphrase from non-terminal stdin " +
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable or run interactively")
errStderrNotTerminal = errors.New( errStderrNotTerminal = errors.New(
"stderr is not a terminal (running in non-interactive mode)") "cannot prompt for passphrase: stderr is not a terminal " +
errNothingEntered = errors.New("nothing was entered") "(running in non-interactive mode). Please set the " +
"SB_UNLOCK_PASSPHRASE environment variable")
errEmptyPassphrase = errors.New("passphrase cannot be empty") errEmptyPassphrase = errors.New("passphrase cannot be empty")
) )
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
// terminal to read the mnemonic from, reading it failed, or it was empty.
var ErrMnemonicNotRead = errors.New("failed to read mnemonic")
// EncryptToRecipient encrypts data to a recipient using age // EncryptToRecipient encrypts data to a recipient using age
// The data parameter should be a LockedBuffer for secure memory handling // The data parameter should be a LockedBuffer for secure memory handling
func EncryptToRecipient( func EncryptToRecipient(
@@ -104,23 +102,6 @@ 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
@@ -167,61 +148,42 @@ func DecryptWithPassphrase(
// ReadPassphrase reads a passphrase securely from the terminal without echoing // ReadPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation // This version is for unlocking and doesn't require confirmation
// Returns a LockedBuffer containing the passphrase for secure memory handling. // Returns a LockedBuffer containing the passphrase for secure memory handling
// Every error it returns wraps ErrPassphraseNotRead.
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) { func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrPassphraseNotRead, EnvUnlockPassphrase)
}
// ReadMnemonic reads a mnemonic from the terminal as ReadPassphrase reads a
// passphrase. Every error it returns wraps ErrMnemonicNotRead.
func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
}
// readFromTerminal reads input from the terminal without echoing it. Every
// error it returns wraps notRead; without a terminal, the error says to set
// envVar instead.
func readFromTerminal(
prompt string, notRead error, envVar string,
) (*memguard.LockedBuffer, error) {
// Check if stdin is a terminal // Check if stdin is a terminal
if !term.IsTerminal(syscall.Stdin) { if !term.IsTerminal(syscall.Stdin) {
// Not a terminal - never read secrets from piped input // Not a terminal - never read passphrases from piped input
// for security reasons // for security reasons
return nil, fmt.Errorf( return nil, errStdinNotTerminal
"%w: %w. Please set the %s environment variable or run interactively",
notRead, errStdinNotTerminal, envVar)
} }
// stdin is a terminal, check if stderr is also a terminal for // stdin is a terminal, check if stderr is also a terminal for
// interactive prompting // interactive prompting
if !term.IsTerminal(syscall.Stderr) { if !term.IsTerminal(syscall.Stderr) {
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable", return nil, errStderrNotTerminal
notRead, errStderrNotTerminal, envVar)
} }
// Both stdin and stderr are terminals - use secure password reading // Both stdin and stderr are terminals - use secure password reading
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
input, err := term.ReadPassword(syscall.Stdin) passphrase, err := term.ReadPassword(syscall.Stdin)
if err != nil { if err != nil {
return nil, fmt.Errorf("%w: %w", notRead, err) return nil, fmt.Errorf("failed to read passphrase: %w", err)
} }
// Print newline to stderr since ReadPassword doesn't echo // Print newline to stderr since ReadPassword doesn't echo
fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr)
if len(input) == 0 { if len(passphrase) == 0 {
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered) return nil, errEmptyPassphrase
} }
// Create a secure buffer and copy the input // Create a secure buffer and copy the passphrase
secureBuffer := memguard.NewBufferFromBytes(input) secureBuffer := memguard.NewBufferFromBytes(passphrase)
// Clear the original input slice // Clear the original passphrase slice
for i := range input { for i := range passphrase {
input[i] = 0 passphrase[i] = 0
} }
return secureBuffer, nil return secureBuffer, nil
-29
View File
@@ -1,29 +0,0 @@
package secret_test
import (
"testing"
"filippo.io/age"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
)
// 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())
}
+1 -1
View File
@@ -10,11 +10,11 @@ import (
"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"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/pkg/agehd"
) )
// realVault is a minimal VaultInterface backed by a real afero filesystem, // realVault is a minimal VaultInterface backed by a real afero filesystem,
+1 -1
View File
@@ -3,7 +3,7 @@ package secret_test
import ( import (
"testing" "testing"
"sneak.berlin/go/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
) )
func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) { func TestDetermineStateDir_ErrorsWhenHomeDirUnavailable(t *testing.T) {
+45 -26
View File
@@ -11,12 +11,13 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime"
"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"
"sneak.berlin/go/secret/pkg/agehd"
) )
const ( const (
@@ -38,6 +39,8 @@ const (
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var ( var (
errNotMacOS = errors.New(
"keychain unlockers are only supported on macOS")
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty") errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
errInvalidKeychainItemName = errors.New("invalid keychain item name format") errInvalidKeychainItemName = errors.New("invalid keychain item name format")
errUnsupportedCurrentUnlocker = errors.New( errUnsupportedCurrentUnlocker = errors.New(
@@ -153,9 +156,20 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID implements Unlocker interface: the name of the unlocker's directory // GetID implements Unlocker interface - generates ID from keychain item name
func (k *KeychainUnlocker) GetID() string { func (k *KeychainUnlocker) GetID() string {
return filepath.Base(k.Directory) // Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
// 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
@@ -382,7 +396,8 @@ func deriveLongTermPrivateKey(
"failed to derive long-term key from mnemonic: %w", err) "failed to derive long-term key from mnemonic: %w", err)
} }
return IdentityToLockedBuffer(ltIdentity), nil // Return the private key in a secure buffer
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
} }
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the // CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
@@ -391,6 +406,12 @@ func deriveLongTermPrivateKey(
func CreateKeychainUnlocker( func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer, fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) { ) (*KeychainUnlocker, error) {
// Check if we're on macOS
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
// Get current vault using the GetCurrentVault function from the same package // Get current vault using the GetCurrentVault function from the same package
vault, err := GetCurrentVault(fs, stateDir) vault, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {
@@ -427,7 +448,10 @@ func CreateKeychainUnlocker(
defer agePrivKeyPassphrase.Destroy() defer agePrivKeyPassphrase.Destroy()
// Step 3: Encrypt age private key with the generated passphrase // Step 3: Encrypt age private key with the generated passphrase
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity) // 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( encryptedAgePrivKey, err := EncryptWithPassphrase(
@@ -470,8 +494,6 @@ func CreateKeychainUnlocker(
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and // writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker). // 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( func writeKeychainUnlocker(
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string, fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
encryptedAgePrivKey, encryptedLtPrivKey []byte, encryptedAgePrivKey, encryptedLtPrivKey []byte,
@@ -492,10 +514,8 @@ func writeKeychainUnlocker(
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err) return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
} }
// Step 8: Write the unlocker's files, the metadata last, then store the // Step 8: Write the unlocker's files and store the data in the keychain,
// data in the keychain // the metadata last
stored := false
err = WriteDir(fs, unlockerDir, func(dir string) error { err = WriteDir(fs, unlockerDir, func(dir string) error {
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient)) err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
if err != nil { if err != nil {
@@ -512,29 +532,19 @@ func writeKeychainUnlocker(
return fmt.Errorf("failed to write encrypted long-term private key: %w", err) return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"), err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
metadataBytes) metadataBytes)
if err != nil { if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err) return fmt.Errorf("failed to write unlocker metadata: %w", err)
} }
err = storeInKeychain(keychainItemName, keychainDataBuffer)
if err != nil {
return fmt.Errorf("failed to store data in keychain: %w", err)
}
stored = true
return nil 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 { if err != nil {
return nil, err return nil, err
} }
@@ -546,6 +556,15 @@ func writeKeychainUnlocker(
}, nil }, nil
} }
// checkMacOSAvailable verifies that we're running on macOS
func checkMacOSAvailable() error {
if runtime.GOOS != "darwin" {
return fmt.Errorf("%w, current OS: %s", errNotMacOS, runtime.GOOS)
}
return nil
}
// validateKeychainItemName validates that a keychain item name is safe for // validateKeychainItemName validates that a keychain item name is safe for
// command execution // command execution
func validateKeychainItemName(itemName string) error { func validateKeychainItemName(itemName string) error {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain // storeInKeychain stores data in the macOS keychain using keybase/go-keychain
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error { func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
if data == nil { if data == nil {
return errNilDataBuffer return fmt.Errorf("data buffer is nil")
} }
if err := validateKeychainItemName(itemName); err != nil { if err := validateKeychainItemName(itemName); err != nil {
return fmt.Errorf("invalid keychain item name: %w", err) return fmt.Errorf("invalid keychain item name: %w", err)
+2 -3
View File
@@ -4,7 +4,6 @@ package secret
import ( import (
"errors" "errors"
"path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -61,9 +60,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
return k.Directory return k.Directory
} }
// GetID returns the unlocker ID, the name of the unlocker's directory // GetID returns the unlocker ID
func (k *KeychainUnlocker) GetID() string { func (k *KeychainUnlocker) GetID() string {
return filepath.Base(k.Directory) return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain"
} }
// GetKeychainItemName returns an error on non-Darwin platforms // GetKeychainItemName returns an error on non-Darwin platforms
+6 -34
View File
@@ -4,13 +4,10 @@ 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"
) )
@@ -72,12 +69,9 @@ func TestKeychainInvalidItemName(t *testing.T) {
testData := memguard.NewBufferFromBytes([]byte("test")) testData := memguard.NewBufferFromBytes([]byte("test"))
defer testData.Destroy() defer testData.Destroy()
// Test an empty item name
err := storeInKeychain("", testData)
require.ErrorIs(t, err, errKeychainItemNameEmpty)
// Test invalid item names // Test invalid item names
invalidNames := []string{ invalidNames := []string{
"", // Empty name
"test space", // Contains space "test space", // Contains space
"test/slash", // Contains slash "test/slash", // Contains slash
"test\\backslash", // Contains backslash "test\\backslash", // Contains backslash
@@ -99,8 +93,9 @@ func TestKeychainInvalidItemName(t *testing.T) {
for _, name := range invalidNames { for _, name := range invalidNames {
err := storeInKeychain(name, testData) err := storeInKeychain(name, testData)
require.ErrorIs(t, err, errInvalidKeychainItemName, assert.Error(t, err, "Expected error for invalid name: %s", name)
"Expected error for invalid name: %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)
@@ -130,7 +125,8 @@ func TestKeychainNilData(t *testing.T) {
// Test storing nil data // Test storing nil data
err := storeInKeychain("test-item", nil) err := storeInKeychain("test-item", nil)
require.ErrorIs(t, err, errNilDataBuffer) assert.Error(t, err, "Expected error when storing nil data")
assert.Contains(t, err.Error(), "data buffer is nil")
} }
func TestKeychainLargeData(t *testing.T) { func TestKeychainLargeData(t *testing.T) {
@@ -189,27 +185,3 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
assert.NoError(t, err, assert.NoError(t, err,
"Deleting non-existent keychain item should not return an error") "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")
}
+2 -2
View File
@@ -7,10 +7,10 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the standard BIP39 test vector mnemonic. // testMnemonic is the standard BIP39 test vector mnemonic.
+6 -9
View File
@@ -1,7 +1,6 @@
package secret package secret
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
@@ -11,11 +10,6 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// ErrPassphraseNotRead is wrapped in every error of ReadPassphrase: there
// is no terminal to read the passphrase from, reading it failed, or it was
// empty. A passphrase unlocker that fails with it 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
@@ -115,9 +109,12 @@ func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements Unlocker interface: the name of the unlocker's directory // GetID implements Unlocker interface - generates ID from creation timestamp
func (p *PassphraseUnlocker) GetID() string { func (p *PassphraseUnlocker) GetID() string {
return filepath.Base(p.Directory) // Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
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
@@ -155,7 +152,7 @@ func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
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, err return nil, fmt.Errorf("failed to read passphrase: %w", err)
} }
return secureBuffer, nil return secureBuffer, nil
+58 -8
View File
@@ -17,11 +17,11 @@ import (
"time" "time"
"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/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// pgpUnlockerType is the type of a PGP unlocker. // pgpUnlockerType is the type of a PGP unlocker.
@@ -297,6 +297,11 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
// 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) {
testGetGPGKeyID(t, fs, unlocker, unlockerDir, metadata, fingerprint)
})
// 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) {
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID) testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
@@ -325,7 +330,7 @@ func testCreatePGPUnlocker(
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
// Create a test vault directory structure // Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil) vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -391,10 +396,10 @@ func testCreatePGPUnlocker(
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType()) t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
} }
// Check that the ID is the name of the unlocker's directory // Check if the key ID includes the GPG fingerprint
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) { if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'", t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'",
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory())) pgpUnlocker.GetID(), fingerprint)
} }
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory()) checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
@@ -499,6 +504,51 @@ func checkPGPUnlockerMetadata(
} }
} }
// testGetGPGKeyID writes PGP unlocker metadata holding the GPG fingerprint
// into unlockerDir and checks that unlocker reads it back.
func testGetGPGKeyID(
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
unlockerDir string, metadata secret.UnlockerMetadata, fingerprint string,
) {
t.Helper()
// 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, unlockerMetadataFile)
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
if err != nil {
t.Fatalf("Failed to marshal metadata: %v", err)
}
err = afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms)
if 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)
}
}
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key // testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
// keyID into unlockerDir and checks that unlocker decrypts it. // keyID into unlockerDir and checks that unlocker decrypts it.
func testPGPUnlockerGetIdentity( func testPGPUnlockerGetIdentity(
+43 -20
View File
@@ -18,10 +18,6 @@ import (
"github.com/spf13/afero" "github.com/spf13/afero"
) )
// gpgNoPublicKeyStatus is the status line gpg writes when it has no key for
// the ID it was asked to list: 9 is gpg's error code for "No public key".
const gpgNoPublicKeyStatus = "[GNUPG:] ERROR keylist.getkey 9\n"
var ( var (
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty") errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
errInvalidGPGKeyID = errors.New("invalid GPG key ID format") errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
@@ -29,10 +25,6 @@ var (
errNilDataBuffer = errors.New("data buffer is nil") errNilDataBuffer = errors.New("data buffer is nil")
) )
// ErrGPGKeyNotFound is returned by ResolveGPGKeyFingerprint for a key ID
// that matches no key in the GPG keyring.
var ErrGPGKeyNotFound = errors.New("GPG key not found")
// Variables to allow overriding in tests // Variables to allow overriding in tests
var ( var (
// GPGEncryptFunc is the function used for GPG encryption // GPGEncryptFunc is the function used for GPG encryption
@@ -163,9 +155,21 @@ func (p *PGPUnlocker) GetDirectory() string {
return p.Directory return p.Directory
} }
// GetID implements Unlocker interface: the name of the unlocker's directory // GetID implements Unlocker interface - generates ID from GPG key ID.
// 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 {
return filepath.Base(p.Directory) // Generate ID using GPG key ID: pgp-<keyid>
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
@@ -180,6 +184,30 @@ 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 time // based on hostname and time
func generatePGPUnlockerName() (string, error) { func generatePGPUnlockerName() (string, error) {
@@ -302,7 +330,7 @@ func encryptPGPUnlockerKeys(
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err) return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
} }
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity) ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyData.Destroy() defer ltPrivKeyData.Destroy()
encryptedLtPrivKey, err := EncryptToRecipient( encryptedLtPrivKey, err := EncryptToRecipient(
@@ -312,7 +340,8 @@ func encryptPGPUnlockerKeys(
"failed to encrypt long-term private key to age unlocker: %w", err) "failed to encrypt long-term private key to age unlocker: %w", err)
} }
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity) // Use memguard to protect the private key in memory
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
defer agePrivateKeyBuffer.Destroy() defer agePrivateKeyBuffer.Destroy()
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID) encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
@@ -375,20 +404,14 @@ func ResolveGPGKeyFingerprint(keyID string) (string, error) {
return "", fmt.Errorf("invalid GPG key ID: %w", err) return "", fmt.Errorf("invalid GPG key ID: %w", err)
} }
// Use GPG to get the full fingerprint for the key. --status-fd 1 adds // Use GPG to get the full fingerprint for the key
// gpg's status lines to the output.
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
context.Background(), context.Background(),
"gpg", "--status-fd", "1", "gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
"--list-keys", "--with-colons", "--fingerprint", keyID,
) )
output, err := cmd.Output() output, err := cmd.Output()
if err != nil { if err != nil {
if strings.Contains(string(output), gpgNoPublicKeyStatus) {
return "", fmt.Errorf("%w: %s", ErrGPGKeyNotFound, keyID)
}
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err) return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
} }
+4 -4
View File
@@ -5,12 +5,12 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
// The GPG key ID and fingerprint passed to CreatePGPUnlocker. // The GPG key ID and fingerprint passed to CreatePGPUnlocker.
@@ -48,7 +48,7 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t) installFakeGPG(t)
base := afero.NewMemMapFs() base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil) vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, 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 {
@@ -87,7 +87,7 @@ func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
mnemonic := testMnemonicBuffer(t) mnemonic := testMnemonicBuffer(t)
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil) _, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic)
require.NoError(t, err) require.NoError(t, err)
first, err := secret.CreatePGPUnlocker( first, err := secret.CreatePGPUnlocker(
+193
View File
@@ -1,18 +1,26 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"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(
@@ -73,6 +81,73 @@ func NewSecret(vault VaultInterface, name string) *Secret {
} }
} }
// GetValue retrieves and decrypts the current version's value, with the
// vault's long-term key derived from mnemonic when it is not nil, else
// obtained through unlocker
func (s *Secret) GetValue(
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
)
// 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)
if mnemonic != nil {
return s.getValueViaMnemonic(version, mnemonic.String())
}
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",
@@ -140,6 +215,124 @@ func (s *Secret) Exists() (bool, error) {
return true, nil return true, nil
} }
// getValueViaMnemonic derives the vault's long-term key from the
// mnemonic and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic(
version *Version, mnemonic string,
) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic 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(mnemonic, 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.
+46 -1
View File
@@ -2,6 +2,7 @@
package secret package secret
import ( import (
"encoding/json"
"errors" "errors"
"os" "os"
"path/filepath" "path/filepath"
@@ -9,9 +10,10 @@ import (
"testing" "testing"
"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"
"sneak.berlin/go/secret/pkg/agehd" "github.com/stretchr/testify/require"
) )
// testMnemonicValue is the standard BIP39 test vector mnemonic. // testMnemonicValue is the standard BIP39 test vector mnemonic.
@@ -319,3 +321,46 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
t.Logf("Secret.Exists() works correctly") t.Logf("Secret.Exists() works correctly")
}) })
} }
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
// GetValue, given the mnemonic, derives the long-term key at the derivation
// index in the vault's metadata. At index 0 it could not decrypt the secret,
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
vaultDir := "/test-config/vaults.d/test-vault"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
vlt := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 1,
mnemonic: mnemonic,
}
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
require.NoError(t, err)
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
err = afero.WriteFile(
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
require.NoError(t, err)
secretName, secretValue := "x", "value"
err = vlt.AddSecret(secretName,
memguard.NewBufferFromBytes([]byte(secretValue)), false)
require.NoError(t, err)
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, secretValue, value.String())
}
+34 -48
View File
@@ -4,7 +4,6 @@ package secret
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
@@ -12,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/macse"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/macse"
) )
const ( const (
@@ -130,9 +129,17 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory return s.Directory
} }
// GetID implements Unlocker interface: the name of the unlocker's directory. // GetID implements Unlocker interface.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
return filepath.Base(s.Directory) hostname, err := os.Hostname()
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.
@@ -209,13 +216,16 @@ func generateSEKeyLabel(vaultName string) (string, error) {
// 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 // The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes. // 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, mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) { ) (*SecureEnclaveUnlocker, error) {
err := checkMacOSAvailable()
if err != nil {
return nil, err
}
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)
@@ -227,26 +237,7 @@ 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: Get the vault's long-term private key // Step 1: Create P-256 key in the Secure Enclave via sc_auth
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
err,
)
}
defer ltPrivKeyData.Destroy()
// 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) Debug("Creating Secure Enclave key", "label", seKeyLabel)
_, seKeyHash, err := macse.CreateKey(seKeyLabel) _, seKeyHash, err := macse.CreateKey(seKeyLabel)
@@ -256,31 +247,17 @@ func CreateSecureEnclaveUnlocker(
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash) Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails // Step 2: Get the vault's long-term private key
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash, ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
ltPrivKeyData)
if err != nil { if err != nil {
deleteErr := macse.DeleteKey(seKeyHash) return nil, fmt.Errorf(
if deleteErr != nil { "failed to get long-term private key: %w",
err = errors.Join(err, fmt.Errorf( err,
"failed to delete SE key %s: %w", seKeyLabel, deleteErr)) )
}
return nil, err
} }
defer ltPrivKeyData.Destroy()
return unlocker, nil // Step 3: Encrypt the long-term key directly with the SE (ECIES)
}
// 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(
@@ -289,6 +266,15 @@ func writeSEUnlocker(
) )
} }
// Step 4: Prepare the unlocker directory's path and metadata
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)
seMetadata := SecureEnclaveUnlockerMetadata{ seMetadata := SecureEnclaveUnlockerMetadata{
UnlockerMetadata: UnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{
Type: seUnlockerType, Type: seUnlockerType,
+2 -3
View File
@@ -4,7 +4,6 @@ package secret
import ( import (
"errors" "errors"
"path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
@@ -68,9 +67,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
return s.Directory return s.Directory
} }
// GetID returns the unlocker ID, the name of the unlocker's directory. // GetID returns the unlocker ID.
func (s *SecureEnclaveUnlocker) GetID() string { func (s *SecureEnclaveUnlocker) GetID() string {
return filepath.Base(s.Directory) return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
} }
// Remove returns an error on non-Darwin platforms. // Remove returns an error on non-Darwin platforms.
+3 -2
View File
@@ -35,8 +35,9 @@ 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 the name of the unlocker's directory // Test GetID returns a formatted string with the creation timestamp
assert.Equal(t, "test-se-unlocker", unlocker.GetID()) expectedID := "2026-01-15.10.30-secure-enclave"
assert.Equal(t, expectedID, unlocker.GetID())
} }
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) { func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
+6 -10
View File
@@ -4,8 +4,6 @@
package secret package secret
import ( import (
"os"
"path/filepath"
"testing" "testing"
"time" "time"
@@ -63,9 +61,11 @@ func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
} }
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata) unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
id := unlocker.GetID()
// The ID is the name of the unlocker's directory // ID should contain the timestamp and "secure-enclave" type
assert.Equal(t, "test", unlocker.GetID()) assert.Contains(t, id, "2026-03-10.14.30")
assert.Contains(t, id, seUnlockerType)
} }
func TestGenerateSEKeyLabel(t *testing.T) { func TestGenerateSEKeyLabel(t *testing.T) {
@@ -108,10 +108,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)
require.Error(t, err)
var cause *os.PathError assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist)
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
} }
+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 // The name of the unlocker's directory, unique in its vault GetID() string // Generate ID based on unlocker type and data
Remove() error // Remove the unlocker and any associated resources Remove() error // Remove the unlocker and any associated resources
} }
+10 -13
View File
@@ -22,10 +22,10 @@ const (
maxVersionsPerDay = 999 maxVersionsPerDay = 999
) )
var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)") var (
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
// ErrNilValueBuffer is returned when a secret's value is given as nil. errNilValueBuffer = errors.New("value buffer is nil")
var ErrNilValueBuffer = errors.New("value buffer is nil") )
// VersionMetadata contains information about a secret version // VersionMetadata contains information about a secret version
type VersionMetadata struct { type VersionMetadata struct {
@@ -138,7 +138,7 @@ func GenerateVersionName(fs afero.Fs, secretDir string) (string, error) {
// process dies part-way. // process dies part-way.
func (sv *Version) Save(value *memguard.LockedBuffer) error { func (sv *Version) Save(value *memguard.LockedBuffer) error {
if value == nil { if value == nil {
return ErrNilValueBuffer return errNilValueBuffer
} }
DebugWith("Saving secret version", DebugWith("Saving secret version",
@@ -175,7 +175,9 @@ 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)
} }
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity) // Store private key in memguard buffer immediately
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
[]byte(versionIdentity.String()))
defer versionPrivateKeyBuffer.Destroy() defer versionPrivateKeyBuffer.Destroy()
DebugWith("Generated version keypair", DebugWith("Generated version keypair",
@@ -557,18 +559,13 @@ 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"). If it // The file contains just the version name (e.g., "20231215.001")
// 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; "+ return "", fmt.Errorf("failed to read current version file: %w", err)
"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))
-31
View File
@@ -1,31 +0,0 @@
package secret
import (
"fmt"
"path/filepath"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)
func TestGenerateVersionNameMaxSerial(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := "/test/secret"
versionsDir := filepath.Join(secretDir, "versions")
// Create 999 versions
today := time.Now().Format("20060102")
for i := 1; i <= 999; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
require.NoError(t, err)
}
// Try to create one more - should fail
_, err := GenerateVersionName(fs, secretDir)
require.ErrorIs(t, err, errMaxVersionsPerDay)
}
+23 -1
View File
@@ -36,16 +36,17 @@ package secret_test
import ( import (
"errors" "errors"
"fmt"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
) )
const ( const (
@@ -126,6 +127,27 @@ func TestGenerateVersionName(t *testing.T) {
assert.NotEqual(t, version1, version2) assert.NotEqual(t, version1, version2)
} }
func TestGenerateVersionNameMaxSerial(t *testing.T) {
t.Parallel()
fs := afero.NewMemMapFs()
secretDir := testSecretDir
versionsDir := filepath.Join(secretDir, "versions")
// Create 999 versions
today := time.Now().Format("20060102")
for i := 1; i <= 999; i++ {
versionName := fmt.Sprintf("%s.%03d", today, i)
err := fs.MkdirAll(filepath.Join(versionsDir, versionName), 0o755)
require.NoError(t, err)
}
// Try to create one more - should fail
_, err := secret.GenerateVersionName(fs, secretDir)
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
}
func TestNewVersion(t *testing.T) { func TestNewVersion(t *testing.T) {
t.Parallel() t.Parallel()
+2 -4
View File
@@ -31,10 +31,8 @@ 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 // ErrNilValueBuffer indicates a nil value buffer was supplied.
// passphrase for an unlocker but no mnemonic to derive the long-term key ErrNilValueBuffer = errors.New("value buffer is nil")
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
// ErrInvalidSecretName indicates a secret name that breaks the naming // ErrInvalidSecretName indicates a secret name that breaks the naming
// rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty; // rule: only ASCII letters, digits, '.', '-', '_' and '/'; not empty;
-138
View File
@@ -1,138 +0,0 @@
package vault_test
import (
"path/filepath"
"testing"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
)
const (
// otherMnemonic is a valid BIP39 mnemonic other than testMnemonic.
otherMnemonic = "legal winner thank year wave sausage worth useful " +
"legal winner thank yellow"
// missingName names no vault, secret or unlocker.
missingName = "missing"
)
// newErrorTestVault creates the vault testVaultName, with the secret
// testSecretName in it, on a new in-memory filesystem.
func newErrorTestVault(t *testing.T) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(afero.NewMemMapFs(), testStateDir,
testVaultName, testMnemonicBuffer(t), nil)
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("value"))
t.Cleanup(value.Destroy)
require.NoError(t, vlt.AddSecret(testSecretName, value, false))
return vlt
}
// TestVaultErrors checks that each failure returns its exported error,
// wrapped or not, so that errors.Is tells it apart from the others.
func TestVaultErrors(t *testing.T) {
t.Parallel()
vaultDir := filepath.Join(testStateDir, "vaults.d", testVaultName)
tests := []struct {
name string
run func(vlt *vault.Vault) error
want error
}{
{"create an existing vault", func(vlt *vault.Vault) error {
_, err := vault.CreateVault(vlt.GetFilesystem(), testStateDir,
testVaultName, nil, nil)
return err
}, vault.ErrVaultExists},
{"select a missing vault", func(vlt *vault.Vault) error {
return vault.SelectVault(vlt.GetFilesystem(), testStateDir, missingName)
}, vault.ErrVaultNotFound},
{"add a nil value", func(vlt *vault.Vault) error {
return vlt.AddSecret(missingName, nil, false)
}, secret.ErrNilValueBuffer},
{"get a missing secret", func(vlt *vault.Vault) error {
_, err := vlt.GetSecret(missingName)
return err
}, vault.ErrSecretNotFound},
{"copy onto an existing secret", func(vlt *vault.Vault) error {
return vlt.CopySecretAllVersions(vlt, testSecretName, testSecretName, false)
}, vault.ErrSecretExists},
{"copy a secret without versions", func(vlt *vault.Vault) error {
const versionless = "versionless"
err := vlt.GetFilesystem().MkdirAll(
filepath.Join(vaultDir, "secrets.d", versionless), secret.DirPerms)
if err != nil {
return err
}
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
}, vault.ErrNoVersions},
{"remove a missing unlocker", func(vlt *vault.Vault) error {
return vlt.RemoveUnlocker(missingName)
}, vault.ErrUnlockerNotFound},
{"select a missing unlocker", func(vlt *vault.Vault) error {
return vlt.SelectUnlocker(missingName)
}, vault.ErrUnlockerNotFound},
{"unlocker of an unknown type", func(vlt *vault.Vault) error {
fs := vlt.GetFilesystem()
err := afero.WriteFile(fs,
filepath.Join(vaultDir, "unlockers.d", "odd", "unlocker-metadata.json"),
[]byte(`{"type":"odd"}`), secret.FilePerms)
if err != nil {
return err
}
err = afero.WriteFile(fs, filepath.Join(vaultDir, "current-unlocker"),
[]byte("odd"), secret.FilePerms)
if err != nil {
return err
}
_, err = vlt.GetCurrentUnlocker()
return err
}, vault.ErrUnsupportedUnlockerType},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.ErrorIs(t, tt.run(newErrorTestVault(t)), tt.want)
})
}
}
// TestGetSecretWithWrongMnemonic checks that getting a secret that exists,
// from a vault the given mnemonic does not open, fails with
// ErrMnemonicMismatch through GetSecret's wrapping, and not with
// ErrSecretNotFound.
func TestGetSecretWithWrongMnemonic(t *testing.T) {
t.Parallel()
created := newErrorTestVault(t)
mnemonic := memguard.NewBufferFromBytes([]byte(otherMnemonic))
t.Cleanup(mnemonic.Destroy)
vlt := vault.NewVault(created.GetFilesystem(), testStateDir, testVaultName)
vlt.SetMnemonic(mnemonic)
_, err := vlt.GetSecret(testSecretName)
require.ErrorIs(t, err, vault.ErrMnemonicMismatch)
require.NotErrorIs(t, err, vault.ErrSecretNotFound)
}
+13 -14
View File
@@ -2,17 +2,16 @@ package vault_test
import ( import (
"bytes" "bytes"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"testing" "testing"
"filippo.io/age" "filippo.io/age"
"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/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// deriveVaultIdentity derives the long-term identity for the given vault // deriveVaultIdentity derives the long-term identity for the given vault
@@ -100,7 +99,7 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
// Create a test vault // Create a test vault
vlt, err := vault.CreateVault(fs, stateDir, testVaultName, vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -148,7 +147,7 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
// Create a test vault - CreateVault writes the public key derived from // Create a test vault - CreateVault writes the public key derived from
// the mnemonic // the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName, vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -224,7 +223,7 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
// Create a test vault - CreateVault writes the public key derived from // Create a test vault - CreateVault writes the public key derived from
// the mnemonic // the mnemonic
vlt, err := vault.CreateVault(fs, stateDir, testVaultName, vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -325,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, testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
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)
} }
@@ -341,10 +340,10 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
} }
for _, name := range invalidNames { for _, name := range invalidNames {
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
if !errors.Is(err, vault.ErrInvalidVaultName) { if err == nil {
t.Errorf("Expected ErrInvalidVaultName creating vault with "+ t.Errorf("Expected error creating vault with invalid name %q, "+
"invalid name %q, got %v", name, err) "but got none", name)
} }
} }
} }
@@ -362,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, testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
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)
} }
@@ -412,12 +411,12 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
// Create two vaults - CreateVault writes the public key derived from // Create two vaults - CreateVault writes the public key derived from
// the mnemonic // the mnemonic
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil) vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault1: %v", err) t.Fatalf("Failed to create vault1: %v", err)
} }
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil) vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault2: %v", err) t.Fatalf("Failed to create vault2: %v", err)
} }
+10 -8
View File
@@ -30,12 +30,12 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"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/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// errUnexpectedValue is returned by concurrent readers when a secret value // errUnexpectedValue is returned by concurrent readers when a secret value
@@ -49,7 +49,7 @@ func TestVersionIntegrationWorkflow(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
// Create vault without a long-term key, which is set up below // Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil, nil) vault, err := CreateVault(fs, testStateDir, "test", 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
@@ -320,10 +320,10 @@ func testVersionSerialLimits(
err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755) err = fs.MkdirAll(filepath.Join(secretDir, versionName), 0o755)
require.NoError(t, err) require.NoError(t, err)
// Should fail to create 1000th version. The error is unexported in // Should fail to create 1000th version
// package secret, whose own test checks that it is the one returned.
_, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir)) _, err = secret.GenerateVersionName(fs, filepath.Dir(secretDir))
require.Error(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "exceeded maximum versions per day")
} }
func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) { func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
@@ -331,18 +331,20 @@ func testVersionErrorCases(t *testing.T, vault *Vault, secretName string) {
// Try to get non-existent version // Try to get non-existent version
_, err := vault.GetSecretVersion(secretName, "99991231.999") _, err := vault.GetSecretVersion(secretName, "99991231.999")
require.ErrorIs(t, err, ErrVersionNotFound) require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
// Try to get version of non-existent secret // Try to get version of non-existent secret
_, err = vault.GetSecretVersion("nonexistent/secret", "") _, err = vault.GetSecretVersion("nonexistent/secret", "")
require.ErrorIs(t, err, ErrSecretNotFound) require.Error(t, err)
// Try to add secret without force when it exists // Try to add secret without force when it exists
failBuffer := memguard.NewBufferFromBytes([]byte("should-fail")) failBuffer := memguard.NewBufferFromBytes([]byte("should-fail"))
defer failBuffer.Destroy() defer failBuffer.Destroy()
err = vault.AddSecret(secretName, failBuffer, false) err = vault.AddSecret(secretName, failBuffer, false)
require.ErrorIs(t, err, ErrSecretExists) require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
} }
// TestVersionConcurrency tests concurrent version operations // TestVersionConcurrency tests concurrent version operations
+3 -96
View File
@@ -1,25 +1,19 @@
package vault package vault
import ( import (
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"syscall" "syscall"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// 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.
// //
@@ -31,12 +25,6 @@ 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
@@ -44,97 +32,16 @@ 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:
var err error return flockStateDir(stateDir)
release, err = flockStateDir(stateDir)
if err != nil {
return nil, err
}
case *afero.MemMapFs: case *afero.MemMapFs:
memFsLock.Lock() memFsLock.Lock()
release = memFsLock.Unlock return memFsLock.Unlock, nil
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
+1 -48
View File
@@ -1,15 +1,13 @@
package vault_test package vault_test
import ( import (
"path/filepath"
"testing" "testing"
"time" "time"
"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"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
const ( const (
@@ -123,51 +121,6 @@ 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) {
+55 -77
View File
@@ -8,11 +8,10 @@ import (
"strings" "strings"
"time" "time"
"filippo.io/age" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Register the GetCurrentVault function with the secret package // Register the GetCurrentVault function with the secret package
@@ -153,17 +152,16 @@ 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 long-term key, nil when there is no mnemonic, and the // It returns the derivation index, public key hash, and family hash.
// derivation index, public key hash, and family hash.
func processMnemonicForVault( func processMnemonicForVault(
fs afero.Fs, stateDir, vaultDir, vaultName string, fs afero.Fs, stateDir, vaultDir, vaultName string,
mnemonicBuffer *memguard.LockedBuffer, mnemonicBuffer *memguard.LockedBuffer,
) (*age.X25519Identity, uint32, string, string, error) { ) (uint32, string, string, error) {
if mnemonicBuffer == nil { if mnemonicBuffer == nil {
secret.Debug("No mnemonic given, vault created without long-term key", secret.Debug("No mnemonic given, 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 nil, 0, "", "", nil return 0, "", "", nil
} }
mnemonic := mnemonicBuffer.String() mnemonic := mnemonicBuffer.String()
@@ -173,14 +171,13 @@ func processMnemonicForVault(
// 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 nil, 0, "", "", return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
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 nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err) return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
} }
// Write the public key // Write the public key
@@ -190,8 +187,7 @@ func processMnemonicForVault(
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey)) err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
if err != nil { if err != nil {
return nil, 0, "", "", return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
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)
@@ -203,33 +199,24 @@ 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 nil, 0, "", "", return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
fmt.Errorf("failed to derive identity for index 0: %w", err)
} }
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String())) familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil return derivationIndex, publicKeyHash, familyHash, nil
} }
// CreateVault creates a new vault and selects it as the current vault. When // CreateVault creates a new vault and selects it as the current vault. When
// mnemonic is not nil, the vault's long-term key is derived from it, and the // mnemonic is not nil, the vault's long-term key is derived from it, and the
// returned vault has it as its Mnemonic; when it is nil, the vault has no // returned vault has it as its Mnemonic; when it is nil, the vault has no
// long-term key until one is imported. When passphrase is not nil, the vault // long-term key until one is imported. It refuses a vault that already
// gets a passphrase unlocker protected by it, as its current unlocker; that // exists before writing anything: creating it again would replace its keys,
// needs a mnemonic. It refuses a vault that already exists before writing // and its secrets could no longer be decrypted. The commands that call it
// anything: creating it again would replace its keys, and its secrets could // hold the state directory lock, so no other command can create the vault
// no longer be decrypted. The commands that call it hold the state directory // between the check and the writes.
// 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( func CreateVault(
fs afero.Fs, stateDir string, name string, fs afero.Fs, stateDir string, name string, mnemonic *memguard.LockedBuffer,
mnemonic, passphrase *memguard.LockedBuffer,
) (*Vault, error) { ) (*Vault, error) {
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir) secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
@@ -253,19 +240,51 @@ func CreateVault(
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists) return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
} }
if passphrase != nil && mnemonic == nil { // Create vault directory structure
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)
err = secret.WriteDir(fs, vaultDir, func(dir string) error { // Create main vault directory
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase) err = fs.MkdirAll(vaultDir, secret.DirPerms)
}) 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, mnemonic)
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)
@@ -283,47 +302,6 @@ func CreateVault(
return vlt, nil 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)
+2 -2
View File
@@ -7,9 +7,9 @@ import (
"fmt" "fmt"
"path/filepath" "path/filepath"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Metadata is an alias for secret.VaultMetadata // Metadata is an alias for secret.VaultMetadata
+4 -4
View File
@@ -5,9 +5,9 @@ import (
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
//nolint:paralleltest // subtests share an in-memory filesystem sequentially //nolint:paralleltest // subtests share an in-memory filesystem sequentially
@@ -304,7 +304,7 @@ func TestWorkflowMismatch(t *testing.T) {
fs := afero.NewOsFs() fs := afero.NewOsFs()
// Test Case 1: Create vault WITH mnemonic (like init command) // Test Case 1: Create vault WITH mnemonic (like init command)
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil) _, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t))
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,7 +321,7 @@ func TestWorkflowMismatch(t *testing.T) {
metadata1.DerivationIndex, metadata1.PublicKeyHash) metadata1.DerivationIndex, metadata1.PublicKeyHash)
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault) // Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil) _, err = vault.CreateVault(fs, tempDir, "work", nil)
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)
} }
+11 -8
View File
@@ -3,10 +3,10 @@ package vault_test
import ( import (
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/vault"
) )
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion // TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
@@ -18,7 +18,7 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName, vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
// Add a legitimate secret so the vault is set up // Add a legitimate secret so the vault is set up
@@ -41,8 +41,10 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
t.Parallel() t.Parallel()
_, err := vlt.GetSecretVersion(name, "") _, err := vlt.GetSecretVersion(name, "")
require.ErrorIs(t, err, vault.ErrInvalidSecretName, require.Error(t, err,
"GetSecretVersion should reject malicious name: %s", name) "GetSecretVersion should reject malicious name: %s", name)
require.Contains(t, err.Error(), "invalid secret name",
"error should indicate invalid name for: %s", name)
}) })
} }
} }
@@ -55,11 +57,12 @@ func TestGetSecretRejectsPathTraversal(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName, vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
_, err = vlt.GetSecret("../../../etc/passwd") _, err = vlt.GetSecret("../../../etc/passwd")
require.ErrorIs(t, err, vault.ErrInvalidSecretName) require.Error(t, err)
require.Contains(t, err.Error(), "invalid secret name")
} }
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject // TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
@@ -70,7 +73,7 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
fs := afero.NewMemMapFs() fs := afero.NewMemMapFs()
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName, vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
require.NoError(t, err) require.NoError(t, err)
maliciousNames := []string{ maliciousNames := []string{
@@ -84,8 +87,8 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
t.Parallel() t.Parallel()
_, err := vlt.GetSecretObject(name) _, err := vlt.GetSecretObject(name)
require.ErrorIs(t, err, vault.ErrInvalidSecretName, require.Error(t, err, "GetSecretObject should reject: %s", name)
"GetSecretObject should reject: %s", name) require.Contains(t, err.Error(), "invalid secret name")
}) })
} }
} }
+2 -2
View File
@@ -11,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// ListSecrets returns a list of secret names in this vault // ListSecrets returns a list of secret names in this vault
@@ -130,7 +130,7 @@ func ValidateSecretName(name string) error {
// AddSecret adds a secret to this vault // AddSecret adds a secret to this vault
func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error { func (v *Vault) AddSecret(name string, value *memguard.LockedBuffer, force bool) error {
if value == nil { if value == nil {
return secret.ErrNilValueBuffer return ErrNilValueBuffer
} }
secret.DebugWith("Adding secret to vault", secret.DebugWith("Adding secret to vault",
+7 -5
View File
@@ -27,12 +27,12 @@ import (
"testing" "testing"
"time" "time"
"git.eeqj.de/sneak/secret/internal/secret"
"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/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the mnemonic used to derive the vault long-term key. // testMnemonic is the mnemonic used to derive the vault long-term key.
@@ -66,7 +66,7 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
t.Helper() t.Helper()
// Create vault without a long-term key, which is set up below // Create vault without a long-term key, which is set up below
vault, err := CreateVault(fs, testStateDir, "test", nil, nil) vault, err := CreateVault(fs, testStateDir, "test", 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
@@ -143,7 +143,8 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
defer failBuffer.Destroy() defer failBuffer.Destroy()
err := vault.AddSecret(testSecretPath, failBuffer, false) err := vault.AddSecret(testSecretPath, failBuffer, false)
require.ErrorIs(t, err, ErrSecretExists) require.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
// Add with force - should create new version // Add with force - should create new version
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true) addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
@@ -308,7 +309,8 @@ func TestVaultGetNonExistentVersion(t *testing.T) {
// Try to get non-existent version // Try to get non-existent version
_, err := vault.GetSecretVersion(testSecretPath, "20991231.999") _, err := vault.GetSecretVersion(testSecretPath, "20991231.999")
require.ErrorIs(t, err, ErrVersionNotFound) require.Error(t, err)
assert.Contains(t, err.Error(), "not found")
} }
func TestUpdateVersionMetadata(t *testing.T) { func TestUpdateVersionMetadata(t *testing.T) {
+30 -62
View File
@@ -11,9 +11,9 @@ import (
"time" "time"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
) )
// Unlocker metadata type strings. // Unlocker metadata type strings.
@@ -188,9 +188,8 @@ func (v *Vault) findUnlockerByID(
return nil, skippedDirPath, nil return nil, skippedDirPath, nil
} }
// ListUnlockers returns the metadata of each unlocker of this vault, keyed // ListUnlockers returns a list of available unlockers for this vault
// by the unlocker's ID, the name of its directory in unlockers.d func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
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
@@ -205,7 +204,7 @@ func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
} }
if !exists { if !exists {
return map[string]UnlockerMetadata{}, nil return []UnlockerMetadata{}, nil
} }
// List directories in unlockers.d // List directories in unlockers.d
@@ -214,7 +213,7 @@ func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
return nil, fmt.Errorf("failed to read unlockers directory: %w", err) return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
} }
unlockers := map[string]UnlockerMetadata{} var unlockers []UnlockerMetadata
for _, file := range files { for _, file := range files {
if !file.IsDir() { if !file.IsDir() {
@@ -223,7 +222,7 @@ func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name()) metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
if ok { if ok {
unlockers[file.Name()] = metadata unlockers = append(unlockers, metadata)
} }
} }
@@ -275,24 +274,6 @@ func (v *Vault) readUnlockerMetadataOrWarn(
return metadata, true 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 // RemoveUnlocker removes an unlocker from this vault. An unlocker
// directory that ListUnlockers skips is removed by its directory name; its // directory that ListUnlockers skips is removed by its directory name; its
// type is unknown, so only the directory is removed. // type is unknown, so only the directory is removed.
@@ -391,31 +372,8 @@ func (v *Vault) CreatePassphraseUnlocker(
return nil, 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() createdAt := time.Now()
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase+"-"+ unlockerDir := filepath.Join(unlockersDir, unlockerTypePassphrase+"-"+
createdAt.UTC().Format(secret.UnlockerTimeFormat)) createdAt.UTC().Format(secret.UnlockerTimeFormat))
// Generate new age keypair for unlocker // Generate new age keypair for unlocker
@@ -425,7 +383,7 @@ func writePassphraseUnlocker(
} }
// Encrypt long-term private key to this unlocker // Encrypt long-term private key to this unlocker
ltPrivKeyBuffer := secret.IdentityToLockedBuffer(ltIdentity) ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
defer ltPrivKeyBuffer.Destroy() defer ltPrivKeyBuffer.Destroy()
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer, encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
@@ -446,24 +404,33 @@ func writePassphraseUnlocker(
} }
// Write the unlocker's files, the metadata last // Write the unlocker's files, the metadata last
err = secret.WriteDir(fs, unlockerDir, func(dir string) error { err = secret.WriteDir(v.fs, unlockerDir, func(dir string) error {
return writeUnlockerFiles(fs, dir, unlockerIdentity, passphrase, return v.writeUnlockerFiles(dir, unlockerIdentity, passphrase,
encryptedLtPrivKey, metadataBytes) encryptedLtPrivKey, metadataBytes)
}) })
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Make the new unlocker the current one // Select the new unlocker by its directory, not by its ID: an old
// passphrase unlocker created in the same minute has the same ID.
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker") currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
err = secret.WriteFileAtomic(fs, currentUnlockerPath, err = secret.WriteFileAtomic(v.fs, currentUnlockerPath,
[]byte(filepath.Base(unlockerDir))) []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 secret.NewPassphraseUnlocker(fs, unlockerDir, metadata), nil for _, oldDir := range oldDirs {
err = secret.RemoveDirAtomic(v.fs, oldDir)
if err != nil {
return nil, fmt.Errorf(
"created and selected the new passphrase unlocker: %w", err)
}
}
return secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata), nil
} }
// passphraseUnlockerDirs returns the directories in unlockersDir that hold // passphraseUnlockerDirs returns the directories in unlockersDir that hold
@@ -529,8 +496,7 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
// writeUnlockerFiles writes the files of a passphrase unlocker into // writeUnlockerFiles writes the files of a passphrase unlocker into
// unlockerDir: its public key, its passphrase-encrypted private key, the // unlockerDir: its public key, its passphrase-encrypted private key, the
// long-term private key encrypted to it, and its metadata, last. // long-term private key encrypted to it, and its metadata, last.
func writeUnlockerFiles( func (v *Vault) writeUnlockerFiles(
fs afero.Fs,
unlockerDir string, unlockerDir string,
unlockerIdentity *age.X25519Identity, unlockerIdentity *age.X25519Identity,
passphrase *memguard.LockedBuffer, passphrase *memguard.LockedBuffer,
@@ -539,14 +505,16 @@ func writeUnlockerFiles(
// Write public key // Write public key
pubKeyPath := filepath.Join(unlockerDir, "pub.age") pubKeyPath := filepath.Join(unlockerDir, "pub.age")
err := secret.WriteFileAtomic(fs, pubKeyPath, err := secret.WriteFileAtomic(v.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
privKeyBuffer := secret.IdentityToLockedBuffer(unlockerIdentity) privKeyStr := unlockerIdentity.String()
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
defer privKeyBuffer.Destroy() defer privKeyBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase) encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
@@ -557,18 +525,18 @@ func writeUnlockerFiles(
// Write encrypted private key // Write encrypted private key
privKeyPath := filepath.Join(unlockerDir, "priv.age") privKeyPath := filepath.Join(unlockerDir, "priv.age")
err = secret.WriteFileAtomic(fs, privKeyPath, encryptedPrivKey) err = secret.WriteFileAtomic(v.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, err = secret.WriteFileAtomic(v.fs,
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey) filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
if err != nil { if err != nil {
return fmt.Errorf("failed to write encrypted long-term private key: %w", err) return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
} }
err = secret.WriteFileAtomic(fs, err = secret.WriteFileAtomic(v.fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes) filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
if err != nil { if err != nil {
return fmt.Errorf("failed to write unlocker metadata: %w", err) return fmt.Errorf("failed to write unlocker metadata: %w", err)
+4 -39
View File
@@ -1,16 +1,15 @@
package vault package vault
import ( import (
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/pkg/agehd"
) )
// Vault represents a secrets vault // Vault represents a secrets vault
@@ -99,8 +98,7 @@ 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, v.withMnemonicAdvice( return nil, fmt.Errorf("failed to get current unlocker: %w", err)
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",
@@ -114,7 +112,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, v.withMnemonicAdvice(err) return nil, err
} }
secret.DebugWith("Successfully obtained long-term identity via unlocker", secret.DebugWith("Successfully obtained long-term identity via unlocker",
@@ -297,36 +295,3 @@ 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)
}
+4 -10
View File
@@ -1,16 +1,15 @@
package vault_test package vault_test
import ( import (
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
) )
func TestAddSecretFailsWithMissingPublicKey(t *testing.T) { func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
@@ -37,13 +36,8 @@ func TestAddSecretFailsWithMissingPublicKey(t *testing.T) {
defer value.Destroy() defer value.Destroy()
err := vlt.AddSecret(testSecretName, value, false) err := vlt.AddSecret(testSecretName, value, false)
require.Error(t, err, "AddSecret should fail when public key is missing")
var cause *os.PathError assert.Contains(t, err.Error(), "failed to read long-term public key")
require.ErrorAs(t, err, &cause)
require.ErrorIs(t, err, os.ErrNotExist,
"AddSecret should fail when public key is missing")
assert.Equal(t, filepath.Join(vaultDir, "pub.age"), cause.Path)
// Verify that the secret directory was NOT created // Verify that the secret directory was NOT created
secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName) secretDir := filepath.Join(vaultDir, "secrets.d", testSecretName)
+5 -32
View File
@@ -2,16 +2,15 @@ package vault_test
import ( import (
"bytes" "bytes"
"errors"
"path/filepath" "path/filepath"
"slices" "slices"
"testing" "testing"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"sneak.berlin/go/secret/internal/secret"
"sneak.berlin/go/secret/internal/vault"
"sneak.berlin/go/secret/pkg/agehd"
) )
// testMnemonic is the shared BIP39 test mnemonic for tests in this package. // testMnemonic is the shared BIP39 test mnemonic for tests in this package.
@@ -73,7 +72,7 @@ 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) testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -299,7 +298,7 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
// Create vault // Create vault
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName, vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
testMnemonicBuffer(t), nil) testMnemonicBuffer(t))
if err != nil { if err != nil {
t.Fatalf("Failed to create vault: %v", err) t.Fatalf("Failed to create vault: %v", err)
} }
@@ -345,29 +344,3 @@ 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")
}
}
-5
View File
@@ -1,5 +0,0 @@
{
"devDependencies": {
"prettier": "3.8.1"
}
}
+32 -49
View File
@@ -1,21 +1,14 @@
# agehd - Deterministic Age Identities from BIP85 # agehd - Deterministic Age Identities from BIP85
The `agehd` package derives deterministic X25519 age identities using BIP85 The `agehd` package derives deterministic X25519 age identities using BIP85 entropy derivation and a deterministic random number generator (DRNG). This package only supports proper BIP85 sources: BIP39 mnemonics and extended private keys (xprv).
entropy derivation and a deterministic random number generator (DRNG). This
package only supports proper BIP85 sources: BIP39 mnemonics and extended private
keys (xprv).
## Features ## Features
- **Deterministic key generation**: Same input always produces the same age - **Deterministic key generation**: Same input always produces the same age identity
identity
- **BIP85 compliance**: Uses the BIP85 standard for entropy derivation - **BIP85 compliance**: Uses the BIP85 standard for entropy derivation
- **Multiple key support**: Generate multiple keys from the same source using - **Multiple key support**: Generate multiple keys from the same source using different indices
different indices - **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private keys (xprv)
- **Two BIP85 input methods**: Support for BIP39 mnemonics and extended private - **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid conflicts
keys (xprv)
- **Vendor/application scoped**: Uses vendor-specific derivation paths to avoid
conflicts
## Derivation Path ## Derivation Path
@@ -26,7 +19,6 @@ m/83696968'/592366788'/733482323'/n'
``` ```
Where: Where:
- `83696968'` is the BIP85 root path ("bip" in ASCII) - `83696968'` is the BIP85 root path ("bip" in ASCII)
- `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff) - `592366788'` is the vendor ID (sha256("berlin.sneak") & 0x7fffffff)
- `733482323'` is the application ID (sha256("secret") & 0x7fffffff) - `733482323'` is the application ID (sha256("secret") & 0x7fffffff)
@@ -42,19 +34,19 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive the first identity (index 0) // Derive the first identity (index 0)
identity, err := agehd.DeriveIdentity(mnemonic, 0) identity, err := agehd.DeriveIdentity(mnemonic, 0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Secret key: %s\n", identity.String()) fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String()) fmt.Printf("Public key: %s\n", identity.Recipient().String())
} }
@@ -68,19 +60,19 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
xprv := "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb" xprv := "xprv9s21ZrQH143K2LBWUUQRFXhucrQqBpKdRRxNVq2zBqsx8HVqFk2uYo8kmbaLLHRdqtQpUm98uKfu3vca1LqdGhUtyoFnCNkfmXRyPXLjbKb"
// Derive the first identity (index 0) from the xprv // Derive the first identity (index 0) from the xprv
identity, err := agehd.DeriveIdentityFromXPRV(xprv, 0) identity, err := agehd.DeriveIdentityFromXPRV(xprv, 0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Secret key: %s\n", identity.String()) fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String()) fmt.Printf("Public key: %s\n", identity.Recipient().String())
} }
@@ -94,20 +86,20 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive multiple identities with different indices // Derive multiple identities with different indices
for i := uint32(0); i < 3; i++ { for i := uint32(0); i < 3; i++ {
identity, err := agehd.DeriveIdentity(mnemonic, i) identity, err := agehd.DeriveIdentity(mnemonic, i)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Identity %d: %s\n", i, identity.Recipient().String()) fmt.Printf("Identity %d: %s\n", i, identity.Recipient().String())
} }
} }
@@ -121,25 +113,25 @@ package main
import ( import (
"fmt" "fmt"
"log" "log"
"sneak.berlin/go/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
) )
func main() { func main() {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// First derive entropy using BIP85 // First derive entropy using BIP85
entropy, err := agehd.DeriveEntropy(mnemonic, 0) entropy, err := agehd.DeriveEntropy(mnemonic, 0)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
// Then create identity from entropy // Then create identity from entropy
identity, err := agehd.IdentityFromEntropy(entropy) identity, err := agehd.IdentityFromEntropy(entropy)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
fmt.Printf("Secret key: %s\n", identity.String()) fmt.Printf("Secret key: %s\n", identity.String())
fmt.Printf("Public key: %s\n", identity.Recipient().String()) fmt.Printf("Public key: %s\n", identity.Recipient().String())
} }
@@ -159,8 +151,7 @@ Derives a deterministic age identity from a BIP39 mnemonic and index.
#### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)` #### `DeriveIdentityFromXPRV(xprv string, n uint32) (*age.X25519Identity, error)`
Derives a deterministic age identity from an extended private key (xprv) and Derives a deterministic age identity from an extended private key (xprv) and index.
index.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index (0, 1, 2, ...) - `n`: The derivation index (0, 1, 2, ...)
@@ -176,8 +167,7 @@ Derives 32 bytes of entropy from a BIP39 mnemonic and index using BIP85.
#### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)` #### `DeriveEntropyFromXPRV(xprv string, n uint32) ([]byte, error)`
Derives 32 bytes of entropy from an extended private key (xprv) and index using Derives 32 bytes of entropy from an extended private key (xprv) and index using BIP85.
BIP85.
- `xprv`: A valid extended private key in xprv format - `xprv`: A valid extended private key in xprv format
- `n`: The derivation index - `n`: The derivation index
@@ -192,27 +182,20 @@ Converts 32 bytes of entropy into an age X25519 identity.
## Implementation Details ## Implementation Details
1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 1. **BIP85 Entropy Derivation**: The package uses the BIP85 standard to derive 64 bytes of entropy from the input source
64 bytes of entropy from the input source 2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 is seeded with the 64-byte entropy
2. **DRNG**: A BIP85 DRNG (Deterministic Random Number Generator) using SHAKE256 3. **Key Generation**: 32 bytes are read from the DRNG to generate the age private key
is seeded with the 64-byte entropy 4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for X25519
3. **Key Generation**: 32 bytes are read from the DRNG to generate the age 5. **Bech32 Encoding**: The key is encoded using Bech32 with the "age-secret-key-" prefix
private key
4. **RFC-7748 Clamping**: The private key is clamped according to RFC-7748 for
X25519
5. **Bech32 Encoding**: The key is encoded using Bech32 with the
"age-secret-key-" prefix
## Security Considerations ## Security Considerations
- The same mnemonic/xprv and index will always produce the same identity - The same mnemonic/xprv and index will always produce the same identity
- Different indices produce cryptographically independent identities - Different indices produce cryptographically independent identities
- The vendor/application scoping prevents conflicts with other BIP85 - The vendor/application scoping prevents conflicts with other BIP85 applications
applications
- The DRNG ensures high-quality randomness for key generation - The DRNG ensures high-quality randomness for key generation
- Private keys are properly clamped for X25519 usage - Private keys are properly clamped for X25519 usage
- Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary - Only accepts proper BIP85 sources (mnemonics and xprv keys), not arbitrary passphrases
passphrases
## Testing ## Testing
@@ -220,4 +203,4 @@ Run the tests with:
```bash ```bash
go test -v ./internal/agehd go test -v ./internal/agehd
``` ```
+1 -1
View File
@@ -14,11 +14,11 @@ import (
"strings" "strings"
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcutil/bech32" "github.com/btcsuite/btcutil/bech32"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/pkg/bip85"
) )
const ( const (
+18 -5
View File
@@ -38,6 +38,7 @@ const (
testMessageLargePattern = "A" testMessageLargePattern = "A"
// Error messages for validation // Error messages for validation
errorMsgNeed32Bytes = "need 32-byte scalar, got"
errorMsgInvalidXPRV = "invalid-xprv" errorMsgInvalidXPRV = "invalid-xprv"
// Test constants for various scenarios // Test constants for various scenarios
@@ -329,17 +330,24 @@ func TestClampFunction(t *testing.T) {
} }
} }
// requireIdentityError asserts that identity derivation failed with // requireIdentityError asserts that identity derivation failed with an
// errInvalidScalarSize and returned no identity. // error containing errorMsg and returned no identity.
func requireIdentityError( func requireIdentityError(
t *testing.T, t *testing.T,
identity *age.X25519Identity, identity *age.X25519Identity,
err error, err error,
errorMsg string,
) { ) {
t.Helper() t.Helper()
if !errors.Is(err, errInvalidScalarSize) { if err == nil {
t.Errorf("expected errInvalidScalarSize, got %v", err) t.Errorf("expected error but got none")
} else if !strings.Contains(err.Error(), errorMsg) {
t.Errorf(
"expected error containing %q, got %q",
errorMsg,
err.Error(),
)
} }
if identity != nil { if identity != nil {
@@ -355,26 +363,31 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
name string name string
entropy []byte entropy []byte
expectError bool expectError bool
errorMsg string
}{ }{
{ {
name: "nil entropy", name: "nil entropy",
entropy: nil, entropy: nil,
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 0",
}, },
{ {
name: "empty entropy", name: "empty entropy",
entropy: []byte{}, entropy: []byte{},
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 0",
}, },
{ {
name: "too short entropy", name: "too short entropy",
entropy: make([]byte, 31), entropy: make([]byte, 31),
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 31",
}, },
{ {
name: "too long entropy", name: "too long entropy",
entropy: make([]byte, 33), entropy: make([]byte, 33),
expectError: true, expectError: true,
errorMsg: errorMsgNeed32Bytes + " 33",
}, },
{ {
name: "valid 32-byte entropy", name: "valid 32-byte entropy",
@@ -406,7 +419,7 @@ func TestIdentityFromEntropyEdgeCases(t *testing.T) {
identity, err := IdentityFromEntropy(tt.entropy) identity, err := IdentityFromEntropy(tt.entropy)
if tt.expectError { if tt.expectError {
requireIdentityError(t, identity, err) requireIdentityError(t, identity, err, tt.errorMsg)
return return
} }
+12 -19
View File
@@ -1,15 +1,10 @@
# BIP85 - Deterministic Entropy From BIP32 Keychains # BIP85 - Deterministic Entropy From BIP32 Keychains
This package implements This package implements [BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which allows for deterministic derivation of entropy from a BIP32 master key. This enables a single seed to generate multiple wallet keys, mnemonics, and random values in a fully deterministic way.
[BIP85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which
allows for deterministic derivation of entropy from a BIP32 master key. This
enables a single seed to generate multiple wallet keys, mnemonics, and random
values in a fully deterministic way.
## Overview ## Overview
BIP85 enables a variety of use cases: BIP85 enables a variety of use cases:
- Generate multiple BIP39 mnemonic seeds from a single master key - Generate multiple BIP39 mnemonic seeds from a single master key
- Derive Bitcoin HD wallet seeds (WIF format) - Derive Bitcoin HD wallet seeds (WIF format)
- Create extended private keys (XPRV) - Create extended private keys (XPRV)
@@ -22,8 +17,8 @@ BIP85 enables a variety of use cases:
```go ```go
import ( import (
"fmt" "fmt"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"sneak.berlin/go/secret/pkg/bip85"
) )
// Parse an existing master key // Parse an existing master key
@@ -119,16 +114,15 @@ m/83696968'/{app}'/{parameters}
``` ```
Where: Where:
- `83696968'` is the BIP85 root path (BIP in ASCII) - `83696968'` is the BIP85 root path (BIP in ASCII)
- `{app}'` is the application number: - `{app}'` is the application number:
- `39'` for BIP39 mnemonics - `39'` for BIP39 mnemonics
- `2'` for HD-WIF keys - `2'` for HD-WIF keys
- `32'` for XPRV - `32'` for XPRV
- `128169'` for HEX data - `128169'` for HEX data
- `707764'` for Base64 passwords - `707764'` for Base64 passwords
- `707785'` for Base85 passwords - `707785'` for Base85 passwords
- `828365'` for RSA keys - `828365'` for RSA keys
- `{parameters}` are application-specific parameters - `{parameters}` are application-specific parameters
## Test Vectors ## Test Vectors
@@ -141,13 +135,12 @@ This implementation passes all the test vectors from the BIP85 specification:
- XPRV - XPRV
- SHAKE256 DRNG output - SHAKE256 DRNG output
The implementation is also compatible with the Python reference implementation's The implementation is also compatible with the Python reference implementation's test vectors for the DRNG functionality.
test vectors for the DRNG functionality.
Run the tests with verbose output to see the test vectors and results: Run the tests with verbose output to see the test vectors and results:
``` ```
go test -v sneak.berlin/go/secret/pkg/bip85 go test -v git.eeqj.de/sneak/secret/pkg/bip85
``` ```
## References ## References
@@ -156,4 +149,4 @@ go test -v sneak.berlin/go/secret/pkg/bip85
- [Python Reference Implementation](https://github.com/ethankosakovsky/bip85) - [Python Reference Implementation](https://github.com/ethankosakovsky/bip85)
- [Bitcoin Core](https://github.com/bitcoin/bitcoin) - [Bitcoin Core](https://github.com/bitcoin/bitcoin)
- [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) - [BIP32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)
- [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) - [BIP39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki)
+26 -4
View File
@@ -59,6 +59,16 @@ var (
// ErrInvalidBase85PwdLen is returned when the Base85 password length // ErrInvalidBase85PwdLen is returned when the Base85 password length
// is out of range. // is out of range.
ErrInvalidBase85PwdLen = errors.New("pwdLen must be between 10 and 80") ErrInvalidBase85PwdLen = errors.New("pwdLen must be between 10 and 80")
// ErrPasswordTooShort is returned when the derived material is
// shorter than the requested password length. It carries only the
// middle of the message, which the caller composes as
// "derived password length <n> is shorter than requested length <m>",
// so the emitted text is unchanged.
ErrPasswordTooShort = errors.New("is shorter than requested length")
// ErrEncodedTooShort is returned when the encoded material is shorter
// than the requested password length. Composed as
// "encoded length <n> is less than requested length <m>".
ErrEncodedTooShort = errors.New("is less than requested length")
) )
// Version bytes for extended keys // Version bytes for extended keys
@@ -371,8 +381,14 @@ func DeriveBase64Password(
// Remove any padding // Remove any padding
encodedStr = strings.TrimRight(encodedStr, "=") encodedStr = strings.TrimRight(encodedStr, "=")
// Slice to the desired password length: 64 bytes of entropy leave 86 // Slice to the desired password length
// characters, the most pwdLen allows if len(encodedStr) < int(pwdLen) {
return "", fmt.Errorf(
"derived password length %d %w %d",
len(encodedStr), ErrPasswordTooShort, pwdLen,
)
}
return encodedStr[:pwdLen], nil return encodedStr[:pwdLen], nil
} }
@@ -395,8 +411,14 @@ func DeriveBase85Password(
// Base85 encode all 64 bytes of entropy using the RFC1924 character set // Base85 encode all 64 bytes of entropy using the RFC1924 character set
encoded := encodeBase85WithRFC1924Charset(entropy) encoded := encodeBase85WithRFC1924Charset(entropy)
// Slice to the desired password length: 64 bytes of entropy give 80 // Slice to the desired password length
// characters, the most pwdLen allows if len(encoded) < int(pwdLen) {
return "", fmt.Errorf(
"encoded length %d %w %d",
len(encoded), ErrEncodedTooShort, pwdLen,
)
}
return encoded[:pwdLen], nil return encoded[:pwdLen], nil
} }
+10 -36
View File
@@ -4,14 +4,13 @@ package bip85_test
import ( import (
"bytes" "bytes"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
"git.eeqj.de/sneak/secret/pkg/bip85"
"github.com/btcsuite/btcd/btcutil/hdkeychain" "github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
"sneak.berlin/go/secret/pkg/bip85"
) )
const ( const (
@@ -1014,13 +1013,14 @@ func TestHexDerivation(t *testing.T) {
func TestInvalidParameters(t *testing.T) { func TestInvalidParameters(t *testing.T) {
t.Parallel() t.Parallel()
logTestVector(t, "Invalid Parameters")
masterKey := mustParseTestMasterKey(t) masterKey := mustParseTestMasterKey(t)
// Test cases for parameter validation // Test cases for parameter validation
testCases := []struct { testCases := []struct {
name string name string
testFunc func() error testFunc func() error
want error
}{ }{
{ {
name: "BIP39 invalid word count", name: "BIP39 invalid word count",
@@ -1030,7 +1030,6 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidWordCount,
}, },
{ {
name: "Base64 password too short", name: "Base64 password too short",
@@ -1040,7 +1039,6 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidBase64PwdLen,
}, },
{ {
name: "Base64 password too long", name: "Base64 password too long",
@@ -1050,7 +1048,6 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidBase64PwdLen,
}, },
{ {
name: "Base85 password too short", name: "Base85 password too short",
@@ -1060,7 +1057,6 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidBase85PwdLen,
}, },
{ {
name: "Base85 password too long", name: "Base85 password too long",
@@ -1070,7 +1066,6 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidBase85PwdLen,
}, },
{ {
name: "Hex data too small", name: "Hex data too small",
@@ -1080,7 +1075,6 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidNumBytes,
}, },
{ {
name: "Hex data too large", name: "Hex data too large",
@@ -1090,43 +1084,23 @@ func TestInvalidParameters(t *testing.T) {
return err return err
}, },
want: bip85.ErrInvalidNumBytes,
}, },
} }
// Run all validation test cases // Run all validation test cases
for _, tc := range testCases { for _, tc := range testCases {
t.Logf("Testing: %s", tc.name)
err := tc.testFunc() err := tc.testFunc()
if !errors.Is(err, tc.want) { if err == nil {
t.Errorf("Expected %v for %s, got %v", tc.want, tc.name, err) t.Errorf("Expected error for %s, but got nil", tc.name)
} else {
t.Logf("Got expected error: %v", err)
t.Logf("RESULT: PASS")
} }
} }
} }
// TestDeriveBIP85EntropyErrors checks that DeriveBIP85Entropy returns
// ErrNotPrivateKey for a public master key, and ErrInvalidPathComponent,
// wrapped, for a path component that is not a number.
func TestDeriveBIP85EntropyErrors(t *testing.T) {
t.Parallel()
masterKey := mustParseTestMasterKey(t)
publicKey, err := masterKey.Neuter()
if err != nil {
t.Fatalf("Failed to get the public key of the master key: %v", err)
}
_, err = bip85.DeriveBIP85Entropy(publicKey, testCase1Path)
if !errors.Is(err, bip85.ErrNotPrivateKey) {
t.Errorf("Expected ErrNotPrivateKey, got %v", err)
}
_, err = bip85.DeriveBIP85Entropy(masterKey, bip85.BIP85_MASTER_PATH+"/x'")
if !errors.Is(err, bip85.ErrInvalidPathComponent) {
t.Errorf("Expected ErrInvalidPathComponent, got %v", err)
}
}
// TestAdditionalDeriveHex tests additional hex derivation scenarios // TestAdditionalDeriveHex tests additional hex derivation scenarios
func TestAdditionalDeriveHex(t *testing.T) { func TestAdditionalDeriveHex(t *testing.T) {
t.Parallel() t.Parallel()
+3 -4
View File
@@ -131,10 +131,9 @@ main() {
if missing make; then pkg_install gnumake make make make; fi if missing make; then pkg_install gnumake make make make; fi
# ---- JS / docs repos ---- # ---- JS / docs repos ----
# prettier, pinned in package.json and yarn.lock, formats the markdown # ensure_node
ensure_node # ensure_yarn
ensure_yarn # install_js_deps
install_js_deps
# ---- Go repos ---- # ---- Go repos ----
if missing go; then pkg_install go golang go go; fi if missing go; then pkg_install go golang go go; fi
+1 -1
View File
@@ -17,7 +17,7 @@ main() {
echo dev)" echo dev)"
fi fi
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)" commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
pkg=sneak.berlin/go/secret/internal/cli pkg=git.eeqj.de/sneak/secret/internal/cli
# Build the file, not the package `./cmd/secret`: a package build # Build the file, not the package `./cmd/secret`: a package build
# also stamps git status into the binary and fails where git cannot # also stamps git status into the binary and fails where git cannot
# read the checkout, instead of falling back to `dev`/`unknown`. # read the checkout, instead of falling back to `dev`/`unknown`.
+1 -22
View File
@@ -1,33 +1,12 @@
#!/bin/sh #!/bin/sh
# script/fmt: format all files (writes): Go with go fmt, markdown with # script/fmt: format all files (writes).
# prettier.
set -eu set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
go fmt ./... go fmt ./...
run_yarn run prettier --write '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
-20
View File
@@ -5,25 +5,6 @@ set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pin in script/bootstrap.
NODE_VERSION="22.17.0"
# script/bootstrap installs node and yarn under nvm and leaves neither
# on the PATH of the shell that called it, so resolve the pinned
# toolchain here the way bootstrap's own install step does. nvm is a
# bash script, hence the subshell.
run_yarn() {
if command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "fmt-check: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() { main() {
cd "$ROOT" cd "$ROOT"
if [ -n "$(gofmt -l .)" ]; then if [ -n "$(gofmt -l .)" ]; then
@@ -31,7 +12,6 @@ main() {
gofmt -l . gofmt -l .
exit 1 exit 1
fi fi
run_yarn run prettier --check '**/*.md' --tab-width 4 --prose-wrap always
} }
main "$@" main "$@"
-8
View File
@@ -1,8 +0,0 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
prettier@3.8.1:
version "3.8.1"
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==