Compare commits
30
Commits
0fbbc5332a
...
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2503f2db96 | ||
|
|
43f66bf369 | ||
|
|
176095e3d1 | ||
|
|
2adc588ace | ||
|
|
0e6a4afb71 | ||
|
|
1a23fd3125 | ||
|
|
23dcea83f9 | ||
|
|
f2f89c8a06 | ||
|
|
015730fb05 | ||
|
|
1d7f78fd0d | ||
|
|
ef79111e2e | ||
|
|
017b8d73bf | ||
|
|
1cc8653981 | ||
|
|
7e4e0f7806 | ||
|
|
db7d2c952e | ||
|
|
5ca615a7a6 | ||
|
|
62967f28d0 | ||
|
|
007254a1f0 | ||
|
|
eb596b8be6 | ||
|
|
596b978cb1 | ||
|
|
24d99819a3 | ||
|
|
1ec0423e6e | ||
|
|
4ed77902d1 | ||
|
|
00713b8677 | ||
|
|
71c386ecbf | ||
|
|
cba526d33f | ||
|
|
fb4481b4f7 | ||
|
|
5ec59862ff | ||
|
|
e640d10964 | ||
|
|
4e562f834f |
+4
-1
@@ -16,6 +16,9 @@ coverage.out
|
|||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
# macOS
|
# macOS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
@@ -23,4 +26,4 @@ coverage.out
|
|||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
# Local settings
|
# Local settings
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
+29
-7
@@ -1,12 +1,34 @@
|
|||||||
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
**/.DS_Store
|
Thumbs.db
|
||||||
|
|
||||||
|
# Editors
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
*.bak
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.sublime-*
|
||||||
|
|
||||||
|
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||||
|
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||||
|
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||||
|
# entry and must not be given a `**/` prefix on the way into one.
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment / secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# This repo. /secret is the built binary, anchored so that it does not
|
||||||
|
# also match the internal/secret/ package directory.
|
||||||
/secret
|
/secret
|
||||||
*.log
|
*.log
|
||||||
cli.test
|
|
||||||
vault.test
|
|
||||||
*.test
|
*.test
|
||||||
settings.local.json
|
settings.local.json
|
||||||
|
|
||||||
# Stale files
|
|
||||||
.cursorrules
|
|
||||||
coverage.out
|
|
||||||
|
|||||||
+66
-2
@@ -10,14 +10,20 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
default: all
|
||||||
|
enable:
|
||||||
|
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||||
|
# left to `default: all`, because it carries the module policy below.
|
||||||
|
- gomodguard_v2
|
||||||
disable:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- exhaustruct # Requires all struct fields
|
||||||
- depguard # Dependency allow/block lists
|
|
||||||
- godot # Requires comments to end with periods
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
# Deprecated: the warning is attached to the old name, so it is
|
||||||
|
# silenced by disabling that name, not by enabling the successor.
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||||
settings:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -28,6 +34,64 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
threshold: 100
|
||||||
|
depguard:
|
||||||
|
# Test-support code must not be compiled into the shipped binary. A
|
||||||
|
# test-support package exists to hand a test privileges the program
|
||||||
|
# itself must never have, so a file that is not a test must not import
|
||||||
|
# one. Test files, and the files inside a package whose directory name
|
||||||
|
# ends in `test`, are where that code belongs, and are exempt.
|
||||||
|
#
|
||||||
|
# The deny list below is the one part of this file a repository is
|
||||||
|
# expected to extend, and the only part it may. depguard matches an
|
||||||
|
# import path against a list of prefixes, so it cannot be told "any path
|
||||||
|
# whose last segment ends in test"; a repository's own test-support
|
||||||
|
# packages have to be named here one at a time, by full import path,
|
||||||
|
# under a module path that differs from repository to repository. Add
|
||||||
|
# them; change nothing else.
|
||||||
|
rules:
|
||||||
|
test-support:
|
||||||
|
list-mode: lax
|
||||||
|
files:
|
||||||
|
- "$all"
|
||||||
|
- "!$test"
|
||||||
|
- "!**/*test/**"
|
||||||
|
deny:
|
||||||
|
- pkg: net/http/httptest
|
||||||
|
desc: >-
|
||||||
|
Test-support code belongs in test files and in packages whose
|
||||||
|
directory name ends in test, not in the shipped binary.
|
||||||
|
# Only decisions already recorded in the Go package defaults are
|
||||||
|
# listed here. Every entry matches the module path exactly.
|
||||||
|
gomodguard_v2:
|
||||||
|
blocked:
|
||||||
|
- module: github.com/rs/zerolog
|
||||||
|
recommendations:
|
||||||
|
- log/slog
|
||||||
|
reason: "Structured logging is stdlib log/slog."
|
||||||
|
# One entry per pre-fork module path, because the later releases
|
||||||
|
# are separate paths. A prefix match would be shorter but would
|
||||||
|
# also reach github.com/go-redis/redismock, the test double for
|
||||||
|
# the successor these entries recommend.
|
||||||
|
- module: github.com/go-redis/redis
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v7
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/go-redis/redis/v8
|
||||||
|
recommendations:
|
||||||
|
- github.com/redis/go-redis/v9
|
||||||
|
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||||
|
- module: github.com/sergi/go-diff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "No unified diff output; use go-udiff."
|
||||||
|
- module: github.com/hexops/gotextdiff
|
||||||
|
recommendations:
|
||||||
|
- github.com/aymanbagabas/go-udiff
|
||||||
|
reason: "Unmaintained fork; use go-udiff."
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
@@ -4,154 +4,160 @@ Version: 2025-06-08
|
|||||||
|
|
||||||
# Instructions and Contextual Information
|
# Instructions and Contextual Information
|
||||||
|
|
||||||
* Be direct, robotic, expert, accurate, and professional.
|
- Be direct, robotic, expert, accurate, and professional.
|
||||||
|
|
||||||
* Do not butter me up or kiss my ass.
|
- Do not butter me up or kiss my ass.
|
||||||
|
|
||||||
* Come in hot with strong opinions, even if they are contrary to the
|
- Come in hot with strong opinions, even if they are contrary to the direction I
|
||||||
direction I am headed.
|
am headed.
|
||||||
|
|
||||||
* If either you or I are possibly wrong, say so and explain your point of
|
- If either you or I are possibly wrong, say so and explain your point of view.
|
||||||
view.
|
|
||||||
|
|
||||||
* Point out great alternatives I haven't thought of, even when I'm not
|
- Point out great alternatives I haven't thought of, even when I'm not asking
|
||||||
asking for them.
|
for them.
|
||||||
|
|
||||||
* Treat me like the world's leading expert in every situation and every
|
- Treat me like the world's leading expert in every situation and every
|
||||||
conversation, and deliver the absolute best recommendations.
|
conversation, and deliver the absolute best recommendations.
|
||||||
|
|
||||||
* I want excellence, so always be on the lookout for divergences from good
|
- I want excellence, so always be on the lookout for divergences from good data
|
||||||
data model design or best practices for object oriented development.
|
model design or best practices for object oriented development.
|
||||||
|
|
||||||
* IMPORTANT: This is production code, not a research or teaching exercise.
|
- IMPORTANT: This is production code, not a research or teaching exercise.
|
||||||
Deliver professional-level results, not prototypes.
|
Deliver professional-level results, not prototypes.
|
||||||
|
|
||||||
* Please read and understand the `README.md` file in the root of the repo
|
- Please read and understand the `README.md` file in the root of the repo for
|
||||||
for project-specific contextual information, including development
|
project-specific contextual information, including development policies,
|
||||||
policies, practices, and current implementation status.
|
practices, and current implementation status.
|
||||||
|
|
||||||
* Be proactive in suggesting improvements or refactorings in places where we
|
- Be proactive in suggesting improvements or refactorings in places where we
|
||||||
diverge from best practices for clean, modular, maintainable code.
|
diverge from best practices for clean, modular, maintainable code.
|
||||||
|
|
||||||
# Policies
|
# Policies
|
||||||
|
|
||||||
1. Before committing, tests must pass (`make test`), linting must pass
|
1. Before committing, tests must pass (`make test`), linting must pass
|
||||||
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
(`make lint`), and code must be formatted (`make fmt`). For go, those
|
||||||
makefile targets should use `go fmt` and `go test -v ./...` and
|
makefile targets should use `go fmt` and `go test -v ./...` and
|
||||||
`golangci-lint run`. When you think your changes are complete, rather
|
`golangci-lint run`. When you think your changes are complete, rather than
|
||||||
than making three different tool calls to check, you can just run `make
|
making three different tool calls to check, you can just run
|
||||||
test && make fmt && make lint` as a single tool call which will save
|
`make test && make fmt && make lint` as a single tool call which will save
|
||||||
time.
|
time.
|
||||||
|
|
||||||
2. Always write a `Makefile` with the default target being `test`, and with
|
2. Always write a `Makefile` with the default target being `test`, and with a
|
||||||
a `fmt` target that formats the code. The `test` target should run all
|
`fmt` target that formats the code. The `test` target should run all tests in
|
||||||
tests in the project, and the `fmt` target should format the code.
|
the project, and the `fmt` target should format the code. `test` should also
|
||||||
`test` should also have a prerequisite target `lint` that should run any
|
have a prerequisite target `lint` that should run any linters that are
|
||||||
linters that are configured for the project.
|
configured for the project.
|
||||||
|
|
||||||
3. After each completed bugfix or feature, the code must be committed. Do
|
3. After each completed bugfix or feature, the code must be committed. Do all of
|
||||||
all of the pre-commit checks (test, lint, fmt) before committing, of
|
the pre-commit checks (test, lint, fmt) before committing, of course.
|
||||||
course.
|
|
||||||
|
|
||||||
4. When creating a very simple test script for testing out a new feature,
|
4. When creating a very simple test script for testing out a new feature,
|
||||||
instead of making a throwaway to be deleted after verification, write an
|
instead of making a throwaway to be deleted after verification, write an
|
||||||
actual test file into the test suite. It doesn't need to be very big or
|
actual test file into the test suite. It doesn't need to be very big or
|
||||||
complex, but it should be a real test that can be run.
|
complex, but it should be a real test that can be run.
|
||||||
|
|
||||||
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
5. When you are instructed to make the tests pass, DO NOT delete tests, skip
|
||||||
tests, or change the tests specifically to make them pass (unless there
|
tests, or change the tests specifically to make them pass (unless there is a
|
||||||
is a bug in the test). This is cheating, and it is bad. You should only
|
bug in the test). This is cheating, and it is bad. You should only be
|
||||||
be modifying the test if it is incorrect or if the test is no longer
|
modifying the test if it is incorrect or if the test is no longer relevant.
|
||||||
relevant. In almost all cases, you should be fixing the code that is
|
In almost all cases, you should be fixing the code that is being tested, or
|
||||||
being tested, or updating the tests to match a refactored implementation.
|
updating the tests to match a refactored implementation.
|
||||||
|
|
||||||
6. When dealing with dates and times or timestamps, always use, display, and
|
6. When dealing with dates and times or timestamps, always use, display, and
|
||||||
store UTC. Set the local timezone to UTC on startup. If the user needs
|
store UTC. Set the local timezone to UTC on startup. If the user needs to see
|
||||||
to see the time in a different timezone, store the user's timezone in a
|
the time in a different timezone, store the user's timezone in a separate
|
||||||
separate field and convert the UTC time to the user's timezone when
|
field and convert the UTC time to the user's timezone when displaying it. For
|
||||||
displaying it. For internal use and internal applications and
|
internal use and internal applications and administrative purposes, always
|
||||||
administrative purposes, always display UTC.
|
display UTC.
|
||||||
|
|
||||||
7. Always write tests, even if they are extremely simple and just check for
|
7. Always write tests, even if they are extremely simple and just check for
|
||||||
correct syntax (ability to compile/import). If you are writing a new
|
correct syntax (ability to compile/import). If you are writing a new feature,
|
||||||
feature, write a test for it. You don't need to target complete
|
write a test for it. You don't need to target complete coverage, but you
|
||||||
coverage, but you should at least test any new functionality you add. If
|
should at least test any new functionality you add. If you are fixing a bug,
|
||||||
you are fixing a bug, write a test first that reproduces the bug, and
|
write a test first that reproduces the bug, and then fix the bug in the code.
|
||||||
then fix the bug in the code.
|
|
||||||
|
|
||||||
8. When implementing new features, be aware of potential side-effects (such
|
8. When implementing new features, be aware of potential side-effects (such as
|
||||||
as state files on disk, data in the database, etc.) and ensure that it is
|
state files on disk, data in the database, etc.) and ensure that it is
|
||||||
possible to mock or stub these side-effects in tests.
|
possible to mock or stub these side-effects in tests.
|
||||||
|
|
||||||
9. Always use structured logging. Log any relevant state/context with the
|
9. Always use structured logging. Log any relevant state/context with the
|
||||||
messages (but do not log secrets). If stdout is not a terminal, output
|
messages (but do not log secrets). If stdout is not a terminal, output the
|
||||||
the structured logs in jsonl format.
|
structured logs in jsonl format.
|
||||||
|
|
||||||
10. Avoid using bare strings or numbers in code, especially if they appear
|
10. Avoid using bare strings or numbers in code, especially if they appear
|
||||||
anywhere more than once. Always define a constant (usually at the top
|
anywhere more than once. Always define a constant (usually at the top of the
|
||||||
of the file) and give it a descriptive name, then use that constant in
|
file) and give it a descriptive name, then use that constant in the code
|
||||||
the code instead of the bare string or number.
|
instead of the bare string or number.
|
||||||
|
|
||||||
11. You do not need to summarize your changes in the chat after making them.
|
11. You do not need to summarize your changes in the chat after making them.
|
||||||
Making the changes and committing them is sufficient. If anything out
|
Making the changes and committing them is sufficient. If anything out of the
|
||||||
of the ordinary happened, please explain it, but in the normal case
|
ordinary happened, please explain it, but in the normal case where you found
|
||||||
where you found and fixed the bug, or implemented the feature, there is
|
and fixed the bug, or implemented the feature, there is no need for the
|
||||||
no need for the end-of-change summary.
|
end-of-change summary.
|
||||||
|
|
||||||
12. Do not create additional files in the root directory of the project
|
12. Do not create additional files in the root directory of the project without
|
||||||
without asking permission first. Configuration files, documentation, and
|
asking permission first. Configuration files, documentation, and build files
|
||||||
build files are acceptable in the root, but source code and other files
|
are acceptable in the root, but source code and other files should be
|
||||||
should be organized in appropriate subdirectories.
|
organized in appropriate subdirectories.
|
||||||
|
|
||||||
## Python-Specific Guidelines
|
## Python-Specific Guidelines
|
||||||
|
|
||||||
1. **Type Annotations (UP006)**: Use built-in collection types directly for type annotations instead of importing from `typing`. This avoids the UP006 linter error.
|
1. **Type Annotations (UP006)**: Use built-in collection types directly for type
|
||||||
|
annotations instead of importing from `typing`. This avoids the UP006 linter
|
||||||
**Good (modern Python 3.9+):**
|
error.
|
||||||
```python
|
|
||||||
def process_items(items: list[str]) -> dict[str, int]:
|
**Good (modern Python 3.9+):**
|
||||||
counts: dict[str, int] = {}
|
|
||||||
return counts
|
```python
|
||||||
```
|
def process_items(items: list[str]) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
**Avoid (triggers UP006):**
|
return counts
|
||||||
```python
|
```
|
||||||
from typing import List, Dict
|
|
||||||
|
**Avoid (triggers UP006):**
|
||||||
def process_items(items: List[str]) -> Dict[str, int]:
|
|
||||||
counts: Dict[str, int] = {}
|
```python
|
||||||
return counts
|
from typing import List, Dict
|
||||||
```
|
|
||||||
|
def process_items(items: List[str]) -> Dict[str, int]:
|
||||||
For optional types, use the `|` operator instead of `Union`:
|
counts: Dict[str, int] = {}
|
||||||
```python
|
return counts
|
||||||
# Good
|
```
|
||||||
def get_value(key: str) -> str | None:
|
|
||||||
return None
|
For optional types, use the `|` operator instead of `Union`:
|
||||||
|
|
||||||
# Avoid
|
```python
|
||||||
from typing import Optional, Union
|
# Good
|
||||||
def get_value(key: str) -> Optional[str]:
|
def get_value(key: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
```
|
|
||||||
|
# Avoid
|
||||||
|
from typing import Optional, Union
|
||||||
|
def get_value(key: str) -> Optional[str]:
|
||||||
|
return None
|
||||||
|
```
|
||||||
|
|
||||||
2. **Import Organization**: Follow the standard Python import order:
|
2. **Import Organization**: Follow the standard Python import order:
|
||||||
- Standard library imports
|
- Standard library imports
|
||||||
- Third-party imports
|
- Third-party imports
|
||||||
- Local application imports
|
- Local application imports
|
||||||
|
|
||||||
Each group should be separated by a blank line.
|
Each group should be separated by a blank line.
|
||||||
|
|
||||||
## Go-Specific Guidelines
|
## Go-Specific Guidelines
|
||||||
|
|
||||||
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate errors via return values.
|
1. **No `panic`, `log.Fatal`, or `os.Exit` in library code.** Always propagate
|
||||||
|
errors via return values.
|
||||||
|
|
||||||
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle errors, not crash.
|
2. **Constructors return `(*T, error)`, not just `*T`.** Callers must handle
|
||||||
|
errors, not crash.
|
||||||
|
|
||||||
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
3. **Wrap errors** with `fmt.Errorf("context: %w", err)` for debuggability.
|
||||||
|
|
||||||
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix the code.
|
4. **Never modify linter config** (`.golangci.yml`) to suppress findings. Fix
|
||||||
|
the code.
|
||||||
|
|
||||||
5. **All PRs must pass `make check` with zero failures.** No exceptions, no "pre-existing issue" excuses.
|
5. **All PRs must pass `make check` with zero failures.** No exceptions, no
|
||||||
|
"pre-existing issue" excuses.
|
||||||
|
|
||||||
6. **Pin external dependencies by commit hash**, not mutable tags.
|
6. **Pin external dependencies by commit hash**, not mutable tags.
|
||||||
|
|||||||
+13
-1
@@ -6,10 +6,19 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
# script/cibuild sets CHECK_EPOCH to the current time, so the RUN steps
|
||||||
|
# below run again on each build, an unchanged tree included, while the
|
||||||
|
# steps above stay cached. ARG is per stage: the build stage declares it too.
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make fmt-check
|
RUN make fmt-check
|
||||||
RUN make lint
|
# Not make lint or make lint-darwin: script/lint and script/lint-darwin are
|
||||||
|
# docker builds, which cannot run in here. These are their commands.
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||||
|
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage — tests and compilation
|
# Build stage — tests and compilation
|
||||||
# golang 1.24.13-alpine (2026-03-10)
|
# golang 1.24.13-alpine (2026-03-10)
|
||||||
@@ -24,6 +33,9 @@ WORKDIR /build
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
|
# As in the lint stage: the RUN steps below run again on each script/cibuild.
|
||||||
|
ARG CHECK_EPOCH
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN make test
|
RUN make test
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Lint image, built by script/lint and script/lint-darwin: golangci-lint runs
|
||||||
|
# as a build step, so a successful build is a clean lint. Works where the
|
||||||
|
# docker daemon is remote and bind mounts are impossible.
|
||||||
|
|
||||||
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# script/lint rebuilds this stage on every run, by this name; the module
|
||||||
|
# download above stays cached.
|
||||||
|
FROM deps AS lint
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# script/lint-darwin rebuilds this stage on every run, by this name. It
|
||||||
|
# checks the code as a macOS build compiles it, but with cgo off, which
|
||||||
|
# leaves out the files that need cgo on macOS (see script/lint-darwin).
|
||||||
|
FROM deps AS lint-darwin
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN GOOS=darwin CGO_ENABLED=0 go vet ./...
|
||||||
|
RUN GOOS=darwin CGO_ENABLED=0 golangci-lint run --config .golangci.yml ./...
|
||||||
@@ -1,13 +1,7 @@
|
|||||||
export CGO_ENABLED=1
|
export CGO_ENABLED=1
|
||||||
export DOCKER_HOST := ssh://root@ber1app1.local
|
|
||||||
|
|
||||||
# Version information
|
.PHONY: default bootstrap setup build test lint lint-darwin fmt fmt-check \
|
||||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
check docker docker-run clean install hooks
|
||||||
GIT_COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
|
||||||
LDFLAGS := -X 'git.eeqj.de/sneak/secret/internal/cli.Version=$(VERSION)' \
|
|
||||||
-X 'git.eeqj.de/sneak/secret/internal/cli.GitCommit=$(GIT_COMMIT)'
|
|
||||||
|
|
||||||
.PHONY: default bootstrap setup test lint fmt fmt-check check docker hooks vet
|
|
||||||
|
|
||||||
default: check
|
default: check
|
||||||
|
|
||||||
@@ -17,13 +11,9 @@ bootstrap:
|
|||||||
setup:
|
setup:
|
||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
build: ./secret
|
# Build ./secret; `make build VERSION=x` stamps x instead of `git describe`
|
||||||
|
build:
|
||||||
./secret: ./internal/*/*.go ./pkg/*/*.go ./cmd/*/*.go ./go.*
|
@script/build
|
||||||
go build -v -ldflags "$(LDFLAGS)" -o $@ cmd/secret/main.go
|
|
||||||
|
|
||||||
vet:
|
|
||||||
go vet ./...
|
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
@@ -34,6 +24,10 @@ fmt:
|
|||||||
lint:
|
lint:
|
||||||
@script/lint
|
@script/lint
|
||||||
|
|
||||||
|
# Type-check and lint the macOS build from Linux (see script/lint-darwin)
|
||||||
|
lint-darwin:
|
||||||
|
@script/lint-darwin
|
||||||
|
|
||||||
check:
|
check:
|
||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
@@ -49,7 +43,7 @@ docker-run:
|
|||||||
clean:
|
clean:
|
||||||
rm -f ./secret
|
rm -f ./secret
|
||||||
|
|
||||||
install: ./secret
|
install: build
|
||||||
cp ./secret $(HOME)/bin/secret
|
cp ./secret $(HOME)/bin/secret
|
||||||
|
|
||||||
fmt-check:
|
fmt-check:
|
||||||
|
|||||||
@@ -1,75 +1,90 @@
|
|||||||
# secret - Local Secret Manager
|
# secret - Local Secret Manager
|
||||||
|
|
||||||
secret is a command-line local secret manager that implements a hierarchical
|
## Description
|
||||||
key architecture for storing and managing sensitive data. It supports
|
|
||||||
multiple vaults, various unlock mechanisms, and provides secure storage
|
|
||||||
using the `age` encryption library.
|
|
||||||
|
|
||||||
It could be used as password manager, but was not designed as such. I
|
`secret` is a WTFPL-licensed Go command-line local secret manager by
|
||||||
created it to scratch an itch for a secure key/value store for replacing a
|
[@sneak](https://sneak.berlin) that implements a hierarchical key architecture
|
||||||
bunch of pgp-encrypted files in a directory structure.
|
for storing and managing sensitive data. It supports multiple vaults, various
|
||||||
|
unlock mechanisms, and provides secure storage using the `age` encryption
|
||||||
|
library.
|
||||||
|
|
||||||
## Core Architecture
|
## Getting Started
|
||||||
|
|
||||||
|
Build from source, then install the binary as `~/bin/secret`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.eeqj.de/sneak/secret.git
|
||||||
|
cd secret
|
||||||
|
make build # writes the binary to ./secret
|
||||||
|
make install # builds it and copies it to ~/bin/secret
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate a mnemonic, create the default vault, then store and read a secret:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
secret generate mnemonic # prints a new BIP39 mnemonic; write it down
|
||||||
|
secret init # asks for that mnemonic and an unlocker passphrase
|
||||||
|
echo "my-password" | secret add myservice/password
|
||||||
|
secret get myservice/password
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rationale
|
||||||
|
|
||||||
|
I created `secret` to scratch an itch: I wanted a secure key/value store to
|
||||||
|
replace a bunch of PGP-encrypted files in a directory structure. It could be
|
||||||
|
used as a password manager, but was not designed as one.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
### Three-Layer Key Hierarchy
|
### Three-Layer Key Hierarchy
|
||||||
|
|
||||||
Secret implements a three-layer key architecture:
|
Secret implements a three-layer key architecture:
|
||||||
|
|
||||||
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide
|
1. **Long-term Keys**: Derived from BIP39 mnemonic phrases, these provide the
|
||||||
the foundation for all encryption
|
foundation for all encryption
|
||||||
2. **Unlockers**: Short-term keys that encrypt the long-term keys,
|
2. **Unlockers**: Short-term keys that encrypt the long-term keys, supporting
|
||||||
supporting multiple authentication methods
|
multiple authentication methods
|
||||||
3. **Version-specific Keys**: Per-version keys that encrypt individual
|
3. **Version-specific Keys**: Per-version keys that encrypt individual secret
|
||||||
secret values
|
values
|
||||||
|
|
||||||
### Version Management
|
### Version Management
|
||||||
|
|
||||||
Each secret maintains a history of versions, with each version having:
|
Each secret maintains a history of versions, with each version having:
|
||||||
|
|
||||||
- Its own encryption key pair
|
- Its own encryption key pair
|
||||||
- Metadata (unencrypted) including creation time and validity period
|
- Metadata including creation time and validity period, encrypted to the
|
||||||
|
version's key pair
|
||||||
- Immutable value storage
|
- Immutable value storage
|
||||||
- Atomic version switching via symlink updates
|
|
||||||
|
The secret's `current` file names its current version. Switching versions
|
||||||
|
replaces that file in one rename, so it is never half-written.
|
||||||
|
|
||||||
### Vault System
|
### Vault System
|
||||||
|
|
||||||
Vaults provide logical separation of secrets, each with its own long-term
|
Vaults provide logical separation of secrets, each with its own long-term key
|
||||||
key and unlocker set. This allows for complete isolation between different
|
and unlocker set. This allows for complete isolation between different contexts
|
||||||
contexts (work, personal, projects).
|
(work, personal, projects).
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
Build from source:
|
|
||||||
```bash
|
|
||||||
git clone <repository>
|
|
||||||
cd secret
|
|
||||||
make build
|
|
||||||
```
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
1. **Initialize the secret manager**:
|
|
||||||
```bash
|
|
||||||
secret init
|
|
||||||
```
|
|
||||||
This creates the default vault and prompts for a BIP39 mnemonic phrase.
|
|
||||||
|
|
||||||
2. **Generate a mnemonic** (if needed):
|
|
||||||
```bash
|
|
||||||
secret generate mnemonic
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Add a secret**:
|
|
||||||
```bash
|
|
||||||
echo "my-password" | secret add myservice/password
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Retrieve a secret**:
|
|
||||||
```bash
|
|
||||||
secret get myservice/password
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands Reference
|
## Commands Reference
|
||||||
|
|
||||||
|
### Confirmation Before Removal
|
||||||
|
|
||||||
|
`secret rm`, `secret version rm`, `secret vault remove` and
|
||||||
|
`secret unlocker remove` destroy data that exists nowhere else. On a terminal
|
||||||
|
each one first asks `[y/N]`, naming exactly what it is about to remove, and goes
|
||||||
|
ahead only on `y` or `yes`; any other answer, a bare Enter included, cancels and
|
||||||
|
removes nothing. The question is asked only after the command's checks have
|
||||||
|
passed, and before it changes anything.
|
||||||
|
|
||||||
|
Whether to ask is decided by stdin, where the answer is read from, so
|
||||||
|
`secret rm foo | tee log` still asks. When stdin is not a terminal, as in a
|
||||||
|
script or a CI job, nobody is there to answer: the command fails at once,
|
||||||
|
removes nothing, and says to pass `--force`.
|
||||||
|
|
||||||
|
`--force` (`-f`) removes without asking, whatever the command removes: a vault
|
||||||
|
that holds secrets and the last unlocker of a vault included. Scripts that
|
||||||
|
remove things pass `--force`.
|
||||||
|
|
||||||
### Initialization
|
### Initialization
|
||||||
|
|
||||||
#### `secret init`
|
#### `secret init`
|
||||||
@@ -78,6 +93,7 @@ Initializes the secret manager with a default vault. Prompts for a BIP39
|
|||||||
mnemonic phrase and creates the initial directory structure.
|
mnemonic phrase and creates the initial directory structure.
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
|
|
||||||
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
- `SB_SECRET_MNEMONIC`: Pre-set mnemonic phrase
|
||||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
|
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase
|
||||||
|
|
||||||
@@ -91,19 +107,22 @@ Lists all available vaults. The current vault is marked.
|
|||||||
|
|
||||||
Creates a new vault with the specified name.
|
Creates a new vault with the specified name.
|
||||||
|
|
||||||
|
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
|
||||||
|
are allowed, and a name must not be empty, `.` or `..`.
|
||||||
|
|
||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
|
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
|
|
||||||
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
#### `secret vault remove <name> [--force]` / `secret vault rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a vault and all its secrets. Like Unix `rm`,
|
**DANGER**: Permanently removes a vault and all its secrets. It first asks for
|
||||||
this command does not ask for confirmation.
|
confirmation, naming the vault and how many secrets it holds (see
|
||||||
|
[Confirmation Before Removal](#confirmation-before-removal)). The last vault
|
||||||
|
cannot be removed. Removing the current vault makes another vault the current
|
||||||
|
one.
|
||||||
|
|
||||||
Requires --force if the vault contains secrets. With --force, will
|
- `--force, -f`: Remove without asking, also a vault that contains secrets
|
||||||
automatically switch to another vault if removing the current one.
|
|
||||||
|
|
||||||
- `--force, -f`: Force removal even if vault contains secrets
|
|
||||||
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
- **NO RECOVERY**: All secrets in the vault will be permanently deleted
|
||||||
|
|
||||||
### Secret Management
|
### Secret Management
|
||||||
@@ -111,51 +130,66 @@ automatically switch to another vault if removing the current one.
|
|||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
|
|
||||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||||
|
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
|
|
||||||
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/`
|
**Secret Name Format:** only ASCII letters, digits, `.`, `-`, `_` and `/` are
|
||||||
are allowed, and a name must not be empty, start with `.` or `/`, end with
|
allowed, and a name must not be empty, start with `.` or `/`, end with `/`,
|
||||||
`/`, contain `//`, or have `..` as a path segment.
|
contain `//`, or have `..` as a path segment.
|
||||||
|
|
||||||
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
- Forward slashes (`/`) are converted to percent signs (`%`) for storage
|
||||||
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
- Examples: `database/password`, `api.key`, `ssh_private_key`
|
||||||
|
|
||||||
#### `secret get <secret-name> [--version <version>]`
|
#### `secret get <secret-name> [--version <version>]`
|
||||||
|
|
||||||
Retrieves and outputs a secret value to stdout.
|
Retrieves and outputs a secret value to stdout.
|
||||||
|
|
||||||
- `--version, -v`: Get a specific version (default: current)
|
- `--version, -v`: Get a specific version (default: current)
|
||||||
|
|
||||||
#### `secret list [filter] [--json]` / `secret ls`
|
#### `secret list [filter] [--json]` / `secret ls`
|
||||||
|
|
||||||
Lists all secrets in the current vault. Optional filter for substring
|
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||||
matching.
|
|
||||||
|
|
||||||
#### `secret remove <secret-name>` / `secret rm` ⚠️ 🛑
|
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a secret and ALL its versions. Like Unix `rm`, this command does not ask for confirmation.
|
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
||||||
|
confirmation, naming the secret, its vault and how many versions it has (see
|
||||||
|
[Confirmation Before Removal](#confirmation-before-removal)).
|
||||||
|
|
||||||
|
- `--force, -f`: Remove without asking
|
||||||
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
- **NO RECOVERY**: Once removed, the secret cannot be recovered
|
||||||
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently deleted
|
- **ALL VERSIONS DELETED**: Every version of the secret will be permanently
|
||||||
|
deleted
|
||||||
|
|
||||||
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
#### `secret move <source> <destination>` / `secret mv` / `secret rename`
|
||||||
|
|
||||||
Moves or renames a secret within the current vault.
|
Moves or renames a secret within the current vault.
|
||||||
|
|
||||||
- Fails if the destination already exists
|
- Fails if the destination already exists
|
||||||
|
- Fails if the destination is the source under another name, such as `foo` for
|
||||||
|
`Foo` on a case-insensitive filesystem (the macOS default); there, to 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 period.
|
Lists all versions of a secret showing creation time, status, and validity
|
||||||
|
period.
|
||||||
|
|
||||||
#### `secret version promote <secret-name> <version>`
|
#### `secret version promote <secret-name> <version>`
|
||||||
|
|
||||||
Promotes a specific version to current by updating the symlink. Does not
|
Promotes a specific version to current by rewriting the secret's `current` file
|
||||||
modify any timestamps, allowing for rollback scenarios.
|
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
||||||
|
|
||||||
#### `secret version remove <secret-name> <version>` / `secret version rm` ⚠️ 🛑
|
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes a specific version of a secret. Like Unix
|
**DANGER**: Permanently removes a specific version of a secret. It first asks
|
||||||
`rm`, this command does not ask for confirmation.
|
for confirmation, naming the version, the secret and its vault (see
|
||||||
|
[Confirmation Before Removal](#confirmation-before-removal)).
|
||||||
|
|
||||||
|
- `--force, -f`: Remove without asking
|
||||||
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
- **NO RECOVERY**: Once removed, this version cannot be recovered
|
||||||
- Cannot remove the current version (must promote another version first)
|
- Cannot remove the current version (must promote another version first)
|
||||||
|
|
||||||
@@ -168,6 +202,7 @@ Generates a cryptographically secure BIP39 mnemonic phrase.
|
|||||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||||
|
|
||||||
Generates and stores a random secret.
|
Generates and stores a random secret.
|
||||||
|
|
||||||
- `--length, -l`: Length of generated secret (default: 16)
|
- `--length, -l`: Length of generated secret (default: 16)
|
||||||
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
- `--type, -t`: Type of secret (`base58`, `alnum`)
|
||||||
- `--force, -f`: Overwrite existing secret
|
- `--force, -f`: Overwrite existing secret
|
||||||
@@ -176,27 +211,40 @@ Generates and stores a random secret.
|
|||||||
|
|
||||||
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
#### `secret unlocker list [--json]` / `secret unlocker ls`
|
||||||
|
|
||||||
Lists all unlockers in the current vault with their metadata.
|
Lists all unlockers in the current vault with their metadata. An unlocker's ID,
|
||||||
|
which `secret unlocker select` and `secret unlocker remove` take, is the name of
|
||||||
|
its directory in `unlockers.d`.
|
||||||
|
|
||||||
#### `secret unlocker add <type> [options]`
|
#### `secret unlocker add <type> [options]`
|
||||||
|
|
||||||
Creates a new unlocker of the specified type:
|
Creates a new unlocker of the specified type:
|
||||||
|
|
||||||
**Types:**
|
**Types:**
|
||||||
|
|
||||||
- `passphrase`: Traditional passphrase-protected unlocker
|
- `passphrase`: Traditional passphrase-protected unlocker
|
||||||
- `pgp`: Uses an existing GPG key for encryption/decryption
|
- `pgp`: Uses an existing GPG key for encryption/decryption
|
||||||
- `keychain`: macOS Keychain integration (macOS only)
|
- `keychain`: macOS Keychain integration (macOS only)
|
||||||
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
- `secure-enclave`: Hardware-backed Secure Enclave protection (macOS only)
|
||||||
|
|
||||||
**Options:**
|
**Options:**
|
||||||
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not specified)
|
|
||||||
|
- `--keyid <id>`: GPG key ID (optional for PGP type, uses default key if not
|
||||||
|
specified)
|
||||||
|
|
||||||
|
A vault has one passphrase unlocker: adding one replaces the one the vault has,
|
||||||
|
which is removed only once the new one is the current unlocker.
|
||||||
|
|
||||||
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
#### `secret unlocker remove <unlocker-id> [--force]` / `secret unlocker rm` ⚠️ 🛑
|
||||||
|
|
||||||
**DANGER**: Permanently removes an unlocker. Like Unix `rm`, this command
|
**DANGER**: Permanently removes an unlocker. It first asks for confirmation,
|
||||||
does not ask for confirmation. Cannot remove the last unlocker if the vault
|
naming the unlocker and its vault and saying whether it is the vault's last
|
||||||
has secrets unless --force is used.
|
unlocker; for the last one it says how many secrets the vault holds and warns
|
||||||
- `--force, -f`: Force removal of last unlocker even if vault has secrets
|
that the vault then opens only with its mnemonic (see
|
||||||
|
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
||||||
|
directory that `secret unlocker list` skips with a warning, because its metadata
|
||||||
|
cannot be read or parsed, is removed by the directory name the warning gives.
|
||||||
|
|
||||||
|
- `--force, -f`: Remove without asking, even the last unlocker
|
||||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||||
vault data will be PERMANENTLY INACCESSIBLE
|
vault data will be PERMANENTLY INACCESSIBLE
|
||||||
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means
|
- **NO RECOVERY**: Removing all unlockers without having your mnemonic means
|
||||||
@@ -210,7 +258,8 @@ Selects an unlocker as the current default for operations.
|
|||||||
|
|
||||||
#### `secret import <secret-name> --source <filename>`
|
#### `secret import <secret-name> --source <filename>`
|
||||||
|
|
||||||
Imports a secret from a file and stores it in the current vault under the given name.
|
Imports a secret from a file and stores it in the current vault under the given
|
||||||
|
name.
|
||||||
|
|
||||||
#### `secret vault import [vault-name]`
|
#### `secret vault import [vault-name]`
|
||||||
|
|
||||||
@@ -220,7 +269,8 @@ Imports a mnemonic phrase into the specified vault (defaults to "default").
|
|||||||
|
|
||||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
Encrypts data using an Age key stored as a secret. If the secret doesn't exist, generates a new Age key.
|
Encrypts data using an Age key stored as a secret. If the secret doesn't exist,
|
||||||
|
generates a new Age key.
|
||||||
|
|
||||||
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret decrypt <secret-name> [--input=file] [--output=file]`
|
||||||
|
|
||||||
@@ -230,13 +280,18 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
|
|
||||||
### Directory Structure
|
### Directory Structure
|
||||||
|
|
||||||
|
The state directory is `berlin.sneak.pkg.secret` in the user's configuration
|
||||||
|
directory: on Linux `$XDG_CONFIG_HOME`, or `~/.config` when that is unset; on
|
||||||
|
macOS `~/Library/Application Support`. When `SB_SECRET_STATE_DIR` is set, it is
|
||||||
|
the state directory instead. On Linux:
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.local/share/secret/
|
~/.config/berlin.sneak.pkg.secret/
|
||||||
├── vaults.d/
|
├── vaults.d/
|
||||||
│ ├── default/
|
│ ├── default/
|
||||||
│ │ ├── unlockers.d/
|
│ │ ├── unlockers.d/
|
||||||
│ │ │ ├── passphrase/ # Passphrase unlocker
|
│ │ │ ├── passphrase-<time>/ # Passphrase unlocker
|
||||||
│ │ │ └── pgp/ # PGP unlocker
|
│ │ │ └── <host>-pgp-<time>/ # PGP unlocker
|
||||||
│ │ ├── secrets.d/
|
│ │ ├── secrets.d/
|
||||||
│ │ │ ├── api%key/ # Secret: api/key
|
│ │ │ ├── api%key/ # Secret: api/key
|
||||||
│ │ │ │ ├── versions/
|
│ │ │ │ ├── versions/
|
||||||
@@ -244,64 +299,76 @@ Decrypts data using an Age key stored as a secret.
|
|||||||
│ │ │ │ │ │ ├── pub.age # Version public key
|
│ │ │ │ │ │ ├── pub.age # Version public key
|
||||||
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
│ │ │ │ │ │ ├── priv.age # Version private key (encrypted)
|
||||||
│ │ │ │ │ │ ├── value.age # Encrypted value
|
│ │ │ │ │ │ ├── value.age # Encrypted value
|
||||||
│ │ │ │ │ │ └── metadata.json # Unencrypted metadata
|
│ │ │ │ │ │ └── metadata.age # Encrypted metadata
|
||||||
│ │ │ │ │ └── 20231216.001/ # Another version
|
│ │ │ │ │ └── 20231216.001/ # Another version
|
||||||
│ │ │ │ └── current -> versions/20231216.001
|
│ │ │ │ └── current # Current version's name: 20231216.001
|
||||||
│ │ │ └── database%password/ # Secret: database/password
|
│ │ │ └── database%password/ # Secret: database/password
|
||||||
│ │ │ ├── versions/
|
│ │ │ ├── versions/
|
||||||
│ │ │ └── current -> versions/20231215.001
|
│ │ │ └── current # Current version's name: 20231215.001
|
||||||
│ │ ├── vault-metadata.json # Vault metadata
|
│ │ ├── vault-metadata.json # Vault metadata
|
||||||
│ │ ├── pub.age # Long-term public key
|
│ │ ├── pub.age # Long-term public key
|
||||||
│ │ └── current-unlocker -> ../unlockers.d/passphrase
|
│ │ └── current-unlocker # Current unlocker's directory name
|
||||||
│ └── work/
|
│ └── work/
|
||||||
│ ├── unlockers.d/
|
│ ├── unlockers.d/
|
||||||
│ ├── secrets.d/
|
│ ├── secrets.d/
|
||||||
│ ├── vault-metadata.json
|
│ ├── vault-metadata.json
|
||||||
│ ├── pub.age
|
│ ├── pub.age
|
||||||
│ └── current-unlocker
|
│ └── current-unlocker
|
||||||
└── currentvault -> vaults.d/default
|
├── currentvault # Current vault's name: default
|
||||||
|
└── lock # Locked by each command that changes anything
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`current`, `currentvault` and `current-unlocker` are plain files that each hold
|
||||||
|
one name. Changing one replaces it in one rename, so it is never half-written.
|
||||||
|
|
||||||
### Key Management and Encryption Flow
|
### Key Management and Encryption Flow
|
||||||
|
|
||||||
#### 1: Long-term Keys
|
#### 1: Long-term Keys
|
||||||
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical deterministic (HD) key derivation
|
|
||||||
|
- **Source**: Derived from BIP39 mnemonic phrases using hierarchical
|
||||||
|
deterministic (HD) key derivation
|
||||||
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
- **Purpose**: Master keys for each vault, used to encrypt secret-specific keys
|
||||||
- **Storage**: Public key stored as `pub.age`, private key encrypted by unlockers
|
- **Storage**: Public key stored as `pub.age`, private key encrypted by
|
||||||
|
unlockers
|
||||||
|
|
||||||
#### 2: Unlockers
|
#### 2: Unlockers
|
||||||
|
|
||||||
Unlockers provide different authentication methods to access the long-term keys:
|
Unlockers provide different authentication methods to access the long-term keys:
|
||||||
|
|
||||||
1. **Passphrase Unlockers**:
|
1. **Passphrase Unlockers**:
|
||||||
- Encrypted with user-provided passphrase
|
- Encrypted with user-provided passphrase
|
||||||
- Stored as encrypted Age keys
|
- Stored as encrypted Age keys
|
||||||
- Cross-platform compatible
|
- Cross-platform compatible
|
||||||
|
|
||||||
2. **PGP Unlockers**:
|
2. **PGP Unlockers**:
|
||||||
- Uses existing GPG key infrastructure
|
- Uses existing GPG key infrastructure
|
||||||
- Leverages existing key management workflows
|
- Leverages existing key management workflows
|
||||||
- Strong authentication through GPG
|
- Strong authentication through GPG
|
||||||
|
|
||||||
3. **Keychain Unlockers** (macOS only):
|
3. **Keychain Unlockers** (macOS only):
|
||||||
- Stores unlock keys in macOS Keychain
|
- Stores unlock keys in macOS Keychain
|
||||||
- Protected by system authentication (Touch ID, password)
|
- Kept on this Mac only: the keychain item is never synced to other devices
|
||||||
- Automatic unlocking when Keychain is unlocked
|
- Automatic unlocking when Keychain is unlocked
|
||||||
- Cross-application integration
|
- Cross-application integration
|
||||||
|
|
||||||
4. **Secure Enclave Unlockers** (macOS):
|
4. **Secure Enclave Unlockers** (macOS):
|
||||||
- Hardware-backed key storage using Apple Secure Enclave
|
- Hardware-backed key storage using Apple Secure Enclave
|
||||||
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer Program required)
|
- Uses `sc_auth` / CryptoTokenKit for SE key management (no Apple Developer
|
||||||
- ECIES encryption: vault long-term key encrypted directly by SE hardware
|
Program required)
|
||||||
- Protected by biometric authentication (Touch ID) or system password
|
- ECIES encryption: the vault long-term key is encrypted directly to the SE
|
||||||
|
key, and only the SE can decrypt it
|
||||||
|
- The SE key cannot leave this Mac; using it asks for no Touch ID or
|
||||||
|
password
|
||||||
|
|
||||||
Each vault maintains its own set of unlockers and one long-term key. The long-term key is encrypted to each unlocker, allowing any authorized unlocker to access vault secrets.
|
Each vault maintains its own set of unlockers and one long-term key. The
|
||||||
|
long-term key is encrypted to each unlocker, allowing any authorized unlocker to
|
||||||
|
access vault secrets.
|
||||||
|
|
||||||
#### 3: Secret-specific Keys
|
#### 3: Secret-specific Keys
|
||||||
|
|
||||||
- Each secret version has its own encryption key pair
|
- Each secret version has its own encryption key pair
|
||||||
- Private key encrypted to the vault's long-term key
|
- Private key encrypted to the vault's long-term key
|
||||||
- Provides forward secrecy and granular access control
|
- A version's private key decrypts only that version's value and metadata
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -310,11 +377,24 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
- `SB_UNLOCK_PASSPHRASE`: Pre-set unlock passphrase (avoids interactive prompt)
|
||||||
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
- `SB_GPG_KEY_ID`: GPG key ID for PGP unlockers
|
||||||
|
|
||||||
|
**Warning:** `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` expose the secret
|
||||||
|
they hold. Other processes running as the same user can read a process's
|
||||||
|
environment (on Linux, from `/proc/<pid>/environ`). Every child process of the
|
||||||
|
shell or script that sets them inherits them, `gpg` included. Set on a command
|
||||||
|
line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
||||||
|
each one as soon as it has read it, so that the programs it runs itself, such as
|
||||||
|
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
||||||
|
started with, and its memory, still hold the value. The interactive prompt,
|
||||||
|
which every command except `secret vault import` offers when the variable is not
|
||||||
|
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||||
|
variables.
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
- Uses the [age encryption library](https://age-encryption.org/) with X25519 keys
|
- Uses the [age encryption library](https://age-encryption.org/) with X25519
|
||||||
|
keys
|
||||||
- All private keys are encrypted at rest
|
- All private keys are encrypted at rest
|
||||||
- No plaintext secrets stored on disk
|
- No plaintext secrets stored on disk
|
||||||
|
|
||||||
@@ -333,7 +413,8 @@ Each vault maintains its own set of unlockers and one long-term key. The long-te
|
|||||||
|
|
||||||
- Hardware token support via PGP/GPG integration
|
- Hardware token support via PGP/GPG integration
|
||||||
- macOS Keychain integration for system-level security
|
- macOS Keychain integration for system-level security
|
||||||
- Secure Enclave integration for hardware-backed key protection (macOS, via `sc_auth` / CryptoTokenKit)
|
- Secure Enclave integration for hardware-backed key protection (macOS, via
|
||||||
|
`sc_auth` / CryptoTokenKit)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
@@ -354,7 +435,7 @@ secret list
|
|||||||
secret get database/prod/password
|
secret get database/prod/password
|
||||||
secret get services/api/key
|
secret get services/api/key
|
||||||
|
|
||||||
# Remove a secret ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
# Remove a secret ⚠️ 🛑 (asks first - PERMANENT!)
|
||||||
secret remove ssh/servers/web01
|
secret remove ssh/servers/web01
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -377,11 +458,12 @@ echo "personal-email-pass" | secret add email/password
|
|||||||
# List all vaults
|
# List all vaults
|
||||||
secret vault list
|
secret vault list
|
||||||
|
|
||||||
# Remove a vault ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
# Remove a vault ⚠️ 🛑 (--force: NO CONFIRMATION - PERMANENT!)
|
||||||
secret vault remove personal --force
|
secret vault remove personal --force
|
||||||
```
|
```
|
||||||
|
|
||||||
### Advanced Authentication
|
### Advanced Authentication
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add multiple unlock methods
|
# Add multiple unlock methods
|
||||||
secret unlocker add passphrase # Password-based
|
secret unlocker add passphrase # Password-based
|
||||||
@@ -395,7 +477,7 @@ secret unlocker list
|
|||||||
# Select a specific unlocker
|
# Select a specific unlocker
|
||||||
secret unlocker select <unlocker-id>
|
secret unlocker select <unlocker-id>
|
||||||
|
|
||||||
# Remove an unlocker ⚠️ 🛑 (NO CONFIRMATION!)
|
# Remove an unlocker ⚠️ 🛑 (asks first!)
|
||||||
secret unlocker remove <unlocker-id>
|
secret unlocker remove <unlocker-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -408,7 +490,7 @@ secret version list database/prod/password
|
|||||||
# Promote an older version to current
|
# Promote an older version to current
|
||||||
secret version promote database/prod/password 20231215.001
|
secret version promote database/prod/password 20231215.001
|
||||||
|
|
||||||
# Remove an old version ⚠️ 🛑 (NO CONFIRMATION - PERMANENT!)
|
# Remove an old version ⚠️ 🛑 (asks first - PERMANENT!)
|
||||||
secret version remove database/prod/password 20231214.001
|
secret version remove database/prod/password 20231214.001
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -428,27 +510,41 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
## Technical Details
|
## Technical Details
|
||||||
|
|
||||||
### Cryptographic Primitives
|
### Cryptographic Primitives
|
||||||
|
|
||||||
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
- **Key Derivation**: BIP32/BIP39 hierarchical deterministic key derivation
|
||||||
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
- **Encryption**: Age (X25519 + ChaCha20-Poly1305)
|
||||||
- **Authentication**: Poly1305 MAC
|
- **Authentication**: Poly1305 MAC
|
||||||
- **Hashing**: Double SHA-256 for public key identification
|
- **Hashing**: Double SHA-256 for public key identification
|
||||||
|
|
||||||
### File Formats
|
### File Formats
|
||||||
- **age Files**: Standard age encryption format (.age extension)
|
|
||||||
- **Metadata**: Unencrypted JSON format with timestamps and type information
|
- **age Files**: Standard age encryption format (.age extension), except
|
||||||
- **Vault Metadata**: JSON containing vault name, creation time, derivation index, and public key hash
|
`pub.age`, which holds an age public key as text
|
||||||
|
- **Metadata**: `vault-metadata.json` and `unlocker-metadata.json` are
|
||||||
|
unencrypted JSON with a creation time, and `unlocker-metadata.json` also
|
||||||
|
records the unlocker's type; a version's `metadata.age` is JSON encrypted to
|
||||||
|
the version's public key
|
||||||
|
- **Vault Metadata**: JSON containing creation time, derivation index, and the
|
||||||
|
public key hashes described below
|
||||||
|
|
||||||
### Vault Management
|
### Vault Management
|
||||||
|
|
||||||
- **Derivation Index**: Each vault uses a unique derivation index from the mnemonic, and thus a unique key pair
|
- **Derivation Index**: Each vault uses a unique derivation index from the
|
||||||
- **Public Key Hash**: Double SHA-256 hash of the index-0 public key identifies vaults from the same mnemonic
|
mnemonic, and thus a unique key pair
|
||||||
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are automatically derived
|
- **Public Key Hash**: Double SHA-256 hash of the vault's public key; the same
|
||||||
|
hash of the index-0 public key identifies vaults from the same mnemonic
|
||||||
|
- **Automatic Key Derivation**: When creating vaults with a mnemonic, keys are
|
||||||
|
automatically derived
|
||||||
|
|
||||||
### Cross-Platform Support
|
### Cross-Platform Support
|
||||||
|
|
||||||
- **macOS**: Full support including Keychain and Secure Enclave integration
|
- **macOS**: Full support including Keychain and Secure Enclave integration
|
||||||
- **Linux**: Full support (excluding macOS-specific features)
|
- **Linux**: Full support (excluding macOS-specific features)
|
||||||
|
|
||||||
|
The keychain and Secure Enclave unlockers need a macOS build with cgo. A macOS
|
||||||
|
build without cgo, such as one cross-compiled from Linux, offers them but fails
|
||||||
|
to add or use them.
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### Threat Model
|
### Threat Model
|
||||||
@@ -474,6 +570,7 @@ secret decrypt encryption/mykey --input document.txt.age --output document.txt
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make build # Build binary
|
make build # Build binary
|
||||||
make test # Run tests
|
make test # Run tests
|
||||||
@@ -481,11 +578,11 @@ make lint # Run linter
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
The project includes comprehensive tests:
|
The project includes comprehensive tests:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
make test # Run all tests
|
make test # Run all tests
|
||||||
go test ./... # Unit tests
|
|
||||||
go test -tags=integration -v ./internal/cli # Integration tests
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Entrypoints
|
## Entrypoints
|
||||||
@@ -493,48 +590,68 @@ go test -tags=integration -v ./internal/cli # Integration tests
|
|||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
standard: normalized scripts in `script/` are the entrypoints for the
|
standard: normalized scripts in `script/` are the entrypoints for the
|
||||||
development workflow, and the Makefile targets are thin shims that call
|
development workflow, and the Makefile targets are thin shims that call them. We
|
||||||
them. We provide:
|
provide:
|
||||||
|
|
||||||
- `script/bootstrap` — install all dependencies (Go, golangci-lint, Go
|
- `script/bootstrap` — install all dependencies (Go, Go module download),
|
||||||
module download), idempotently
|
idempotently; golangci-lint is not 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
|
- `script/projectname` — output the project name (`secret`); used by other
|
||||||
other scripts such as `script/docker`
|
scripts such as `script/docker`
|
||||||
- `script/test` — run `go vet` and the test suite (verbose rerun on
|
- `script/build` — build the `secret` binary into the repo root, stamping the
|
||||||
failure)
|
version (`VERSION` from the environment, else `git describe`) and the git
|
||||||
- `script/lint` — run `golangci-lint`
|
commit
|
||||||
|
- `script/test` — run `go vet` and the test suite (verbose rerun on failure)
|
||||||
|
- `script/lint` — run `golangci-lint` in docker only: builds `Dockerfile.lint`,
|
||||||
|
where the linter is a build step that runs on every call, also on an unchanged
|
||||||
|
tree
|
||||||
|
- `script/lint-darwin` — run `go vet` and `golangci-lint` in docker on the code
|
||||||
|
as a macOS build compiles it (`GOOS=darwin`), which a Linux build never
|
||||||
|
compiles; cgo is off, so the keychain unlocker's calls into the keychain
|
||||||
|
(`internal/secret/keychainunlocker_cgo.go`, and `keychainunlocker_test.go`)
|
||||||
|
and the Secure Enclave bindings (`internal/macse`) are not checked
|
||||||
- `script/fmt` — format all Go code (writes)
|
- `script/fmt` — format all Go code (writes)
|
||||||
- `script/fmt-check` — check formatting without writing
|
- `script/fmt-check` — check formatting without writing
|
||||||
- `script/check` — run `script/test`, `script/lint`, and
|
- `script/check` — run `script/test`, `script/lint`, `script/lint-darwin`, and
|
||||||
`script/fmt-check`
|
`script/fmt-check`
|
||||||
- `script/docker` — build the Docker image tagged with the project name
|
- `script/docker` — build the Docker image tagged with the project name
|
||||||
- `script/cibuild` — CI entrypoint: `docker build --ulimit
|
- `script/cibuild` — CI entrypoint: `docker build --ulimit memlock=-1:-1 .`
|
||||||
memlock=-1:-1 .` (memguard needs mlock; the Dockerfile runs the
|
(memguard needs mlock; the Dockerfile runs the checks), with a new
|
||||||
checks)
|
`CHECK_EPOCH` build argument on every run so the checks run again on an
|
||||||
- `script/precommit` — pre-commit checks: `go mod tidy` verification,
|
unchanged tree
|
||||||
then `script/check`
|
- `script/precommit` — pre-commit checks: `go mod tidy` verification, then
|
||||||
- `script/install-precommit` — install the git pre-commit hook that
|
`script/check`
|
||||||
runs `script/precommit`
|
- `script/install-precommit` — install the git pre-commit hook that runs
|
||||||
|
`script/precommit`
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain, and Secure Enclave unlockers
|
- **Multiple Authentication Methods**: Supports passphrase, PGP, macOS Keychain,
|
||||||
|
and Secure Enclave unlockers
|
||||||
- **Vault Isolation**: Complete separation between different vaults
|
- **Vault Isolation**: Complete separation between different vaults
|
||||||
- **Per-Secret Encryption**: Each secret has its own encryption key
|
- **Per-Secret Encryption**: Each secret has its own encryption key
|
||||||
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
- **BIP39 Mnemonic Support**: Keyless operation using mnemonic phrases
|
||||||
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
- **Cross-Platform**: Works on macOS, Linux, and other Unix-like systems
|
||||||
|
|
||||||
# Author
|
## TODO
|
||||||
|
|
||||||
Made with love and lots of expensive SOTA AI by
|
Open work is tracked on the
|
||||||
[sneak](https://sneak.berlin) in Berlin in the summer of 2025.
|
[issue tracker](https://git.eeqj.de/sneak/secret/issues), which is
|
||||||
|
authoritative. The work to be done before 1.0 is the
|
||||||
|
[`1.0.0` milestone](https://git.eeqj.de/sneak/secret/milestone/12). `TODO.md`
|
||||||
|
records the steps completed so far.
|
||||||
|
|
||||||
Released as a free software gift to the world, no strings attached, under
|
## License
|
||||||
the [WTFPL](https://www.wtfpl.net/) license.
|
|
||||||
|
Released as a free software gift to the world, no strings attached, under the
|
||||||
|
[WTFPL](https://www.wtfpl.net/) license; see [`LICENSE`](LICENSE).
|
||||||
|
|
||||||
|
## Author
|
||||||
|
|
||||||
|
Made with love and lots of expensive SOTA AI by [@sneak](https://sneak.berlin)
|
||||||
|
in Berlin in the summer of 2025.
|
||||||
|
|
||||||
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
Contact: [sneak@sneak.berlin](mailto:sneak@sneak.berlin)
|
||||||
|
|
||||||
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
[https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2](https://keys.openpgp.org/vks/v1/by-fingerprint/5539AD00DE4C42F3AFE11575052443F4DF2A55C2)
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,355 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch (from `main`)
|
- branch from `next`
|
||||||
* do the work in Next Step
|
- do the Next Step: the next open issue in the `1.0.0` milestone
|
||||||
* move Next Step to the top of Completed Steps
|
- log it at the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
- commit (`TODO.md` changes in the same commit as the work)
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
- push, and open a PR against `next`
|
||||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
|
||||||
* push
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags. TODO.md carries open 1.0 security blockers. Work in
|
pre-1.0. No git tags. Open work is tracked on the issue tracker, which is
|
||||||
flight on branch secure-enclave-unlocker (clean tree as of 2026-07-06).
|
authoritative.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Bring the repo into policy compliance in one commit:
|
Take the next open issue in the `1.0.0` milestone:
|
||||||
|
https://git.eeqj.de/sneak/secret/milestone/12
|
||||||
- Add fmt-check and hooks targets to the Makefile (test/lint/fmt/check/
|
|
||||||
docker already exist).
|
|
||||||
- Add REPO_POLICIES.md and .editorconfig.
|
|
||||||
- Add .gitea/workflows/check.yml running make check.
|
|
||||||
- Verify Dockerfile base images are pinned by sha256.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-04: 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
|
||||||
|
`golangci-lint` in docker on the code as a macOS build compiles it
|
||||||
|
(`GOOS=darwin`), with cgo off
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/50). `script/check` runs it, and
|
||||||
|
the `Dockerfile` lint stage runs its commands, so `script/cibuild` does too.
|
||||||
|
Before, CI on Linux never compiled the files built only for macOS. Compiling
|
||||||
|
cgo code for macOS needs Apple's SDK headers, and both `internal/macse` and
|
||||||
|
`github.com/keybase/go-keychain` are cgo on macOS. So the three functions
|
||||||
|
that call `go-keychain` moved from `keychainunlocker.go` to
|
||||||
|
`keychainunlocker_cgo.go`, built only with cgo on macOS like
|
||||||
|
`macse_darwin.go`. A macOS build without cgo, which before did not compile,
|
||||||
|
gets `keychainunlocker_nocgo.go` and the `macse` stub instead, whose errors
|
||||||
|
say the keychain or Secure Enclave needs a macOS build with cgo. The check
|
||||||
|
covers the rest of the keychain unlocker, the Secure Enclave unlocker and
|
||||||
|
the macOS-only tests other than `keychainunlocker_test.go`, whose lint
|
||||||
|
findings are fixed. For the length and complexity limits, parts of
|
||||||
|
`GetIdentity`, `getLongTermPrivateKey` and `CreateKeychainUnlocker` moved
|
||||||
|
into functions of their own, and the Secure Enclave unlocker derives the
|
||||||
|
long-term key from the mnemonic through the same function as the keychain
|
||||||
|
unlocker instead of a copy of it. Lines over 88 columns in the files the
|
||||||
|
check cannot see are wrapped.
|
||||||
|
- 2026-10-04: `secret rm`, `secret version rm`, `secret vault remove` and
|
||||||
|
`secret unlocker remove` ask `[y/N]` before removing anything
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/39), naming what they remove: the
|
||||||
|
secret, its vault and its version count; the version, secret and vault; the
|
||||||
|
vault and its secret count; the unlocker, its vault and whether it is the
|
||||||
|
last, and for the last the vault's secret count and that the vault then
|
||||||
|
opens only with its mnemonic. Only `y` or `yes` goes ahead. Without
|
||||||
|
`--force`, a command whose stdin is not a terminal fails at once. `--force`
|
||||||
|
(now also on `rm` and `version rm`) removes without asking; it replaces the
|
||||||
|
old refusals to remove a vault with secrets or the last unlocker of one
|
||||||
|
without `--force`, which the question now covers. The checks run, and the
|
||||||
|
question is asked, before the state directory lock is taken; under the
|
||||||
|
lock the checks run again, and if they would ask a different question,
|
||||||
|
nothing is removed. `secret rm` fails when it cannot count the versions.
|
||||||
|
- 2026-10-04: A crash while an unlocker is being replaced no longer leaves a
|
||||||
|
current unlocker that cannot open the vault
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/71). Every new unlocker gets a
|
||||||
|
directory of its own, named with the time to the nanosecond:
|
||||||
|
`passphrase-<time>`, `<host>-pgp-<time>`, and for a keychain or Secure
|
||||||
|
Enclave unlocker the keychain item or Secure Enclave key, which names the
|
||||||
|
directory, carries the time instead of the day. `secret.WriteDir` fails on a
|
||||||
|
directory that exists instead of writing into it. `unlocker add passphrase`
|
||||||
|
writes the new unlocker, makes it current, and only then removes the vault's
|
||||||
|
other passphrase unlockers; a crash between the last two steps leaves the old
|
||||||
|
one beside the new, and the old passphrase still opens the vault through it
|
||||||
|
until the next `unlocker add passphrase` or an `unlocker remove` removes it.
|
||||||
|
A PGP, keychain or Secure Enclave unlocker added on the same host and day as
|
||||||
|
another of its type is added beside it instead of replacing it.
|
||||||
|
- 2026-10-04: `SB_SECRET_MNEMONIC` and `SB_UNLOCK_PASSPHRASE` are read once
|
||||||
|
per command, in its `RunE`, into locked buffers on the CLI `Instance`, and
|
||||||
|
unset at once, so that no program the command runs, `gpg` included,
|
||||||
|
inherits them (https://git.eeqj.de/sneak/secret/issues/60). Nothing below
|
||||||
|
the command reads the environment; the buffers are passed down:
|
||||||
|
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
|
||||||
|
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
|
||||||
|
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
|
||||||
|
constructors take both. `CreatePGPUnlocker` sets both on the vault it
|
||||||
|
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
|
||||||
|
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
|
||||||
|
`vault create` no longer put the mnemonic into the environment. Unsetting
|
||||||
|
erases nothing: the starting environment (`/proc/<pid>/environ`) and
|
||||||
|
memory still hold the value. The README warns against both variables.
|
||||||
|
- 2026-10-04: `.golangci.yml` is again the canonical file from
|
||||||
|
`sneak/prompts`, byte for byte
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/66). It runs `gomodguard_v2`
|
||||||
|
in place of the deprecated `gomodguard`, so the lint no longer warns,
|
||||||
|
and enables `depguard` with a rule that keeps `net/http/httptest` out of
|
||||||
|
non-test files. Neither raised a finding in this repo.
|
||||||
|
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
|
||||||
|
the vault's long-term key as adding a passphrase unlocker does, with the
|
||||||
|
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
|
||||||
|
mnemonic, checked against the vault, or else from the current unlocker.
|
||||||
|
Before, it used the keychain unlocker's helper, which on every platform
|
||||||
|
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
|
||||||
|
key, getting the long-term key once from the mnemonic and once from a
|
||||||
|
passphrase unlocker, and reads a secret through the new unlocker.
|
||||||
|
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
|
||||||
|
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
||||||
|
state the rule. `vault create`, `vault import`, `vault select`,
|
||||||
|
`vault remove`, both vault names of `mv` and shell completion of a
|
||||||
|
`vault:secret` argument check the name as typed with
|
||||||
|
`vault.ValidateVaultName` before building any path from it. Before,
|
||||||
|
`vault import ..` wrote a long-term key and an unlocker into the state
|
||||||
|
directory itself, and `vault select ..` made that the current vault.
|
||||||
|
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
|
||||||
|
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
|
||||||
|
current time as the `CHECK_EPOCH` build argument, which both the lint
|
||||||
|
and the build stage of the `Dockerfile` declare after their module
|
||||||
|
download, so the `RUN` steps below the argument run again on each
|
||||||
|
build while the base images and module downloads stay cached. Before,
|
||||||
|
a second run on the same tree took every check from the build cache
|
||||||
|
and reported success having run nothing.
|
||||||
|
- 2026-10-04: A failed unlocker add no longer leaves a partial unlocker
|
||||||
|
directory (https://git.eeqj.de/sneak/secret/issues/48).
|
||||||
|
`secret unlocker add pgp` resolves the GPG key's fingerprint once, for
|
||||||
|
its duplicate check, and passes it to `CreatePGPUnlocker` to record.
|
||||||
|
`CreatePGPUnlocker` and `CreateKeychainUnlocker` get the long-term key
|
||||||
|
and encrypt everything before writing anything. All four unlocker
|
||||||
|
types write their files through `secret.WriteDir`: a new unlocker is
|
||||||
|
built in a temporary directory, renamed into place when complete and
|
||||||
|
removed on a failure.
|
||||||
|
- 2026-10-04: `secret unlocker select` and `secret unlocker remove`
|
||||||
|
skip, with the warning `unlocker list` gives, an unlocker directory
|
||||||
|
whose metadata file cannot be checked for, read or parsed, instead of
|
||||||
|
failing when it sorts before the unlocker asked for. Such a directory,
|
||||||
|
or one without a metadata file, is removed by its directory name, the
|
||||||
|
name the warning gives; only the directory is removed, since its type
|
||||||
|
is unknown. Removing one whose metadata file is missing or corrupt
|
||||||
|
never counts as removing the last unlocker. Removing one whose metadata
|
||||||
|
file cannot be checked for or read always does, since it may be the
|
||||||
|
only working unlocker, so in a vault with secrets it needs `--force`.
|
||||||
|
- 2026-10-04: A failed command prints its error once, without the usage
|
||||||
|
text after it (https://git.eeqj.de/sneak/secret/issues/41). Usage is
|
||||||
|
still printed for a command called wrongly: wrong number of arguments,
|
||||||
|
unknown flag, bad flag value, missing required flag, or flags that
|
||||||
|
break a flag group (mutually exclusive, required together, one
|
||||||
|
required). The root command's `PersistentPreRunE` turns usage off.
|
||||||
|
Cobra checks arguments and flag values before that hook but required
|
||||||
|
flags and flag groups only after it, so the hook checks those two
|
||||||
|
first. Root `SilenceUsage` would have hidden usage for all of these.
|
||||||
|
- 2026-10-04: `secret get` keeps the secret in locked memory until it
|
||||||
|
writes it out (https://git.eeqj.de/sneak/secret/issues/37):
|
||||||
|
`Vault.GetSecret` and `Vault.GetSecretVersion` return a
|
||||||
|
`*memguard.LockedBuffer`, which every caller destroys, and `secret get`
|
||||||
|
writes its bytes straight to stdout, still with no trailing newline.
|
||||||
|
Before, the value was copied into ordinary memory that nothing wiped,
|
||||||
|
and `get --version` also wrote it to the debug log.
|
||||||
|
- 2026-10-04: The `Makefile` no longer sets `DOCKER_HOST`, so its docker
|
||||||
|
targets use the local docker daemon, or whatever `DOCKER_HOST` the
|
||||||
|
environment sets. `make build` calls the new `script/build`, which
|
||||||
|
stamps the version (`VERSION` from the environment, else
|
||||||
|
`git describe`) and the git commit as before. `build`, `clean`,
|
||||||
|
`install` and `docker-run` are in `.PHONY`; `make install` depends on
|
||||||
|
`build`. The `vet` target is gone: `script/test` runs `go vet` first.
|
||||||
|
- 2026-10-04: `.gitignore` is the org's standard file, which ignores
|
||||||
|
`.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
|
||||||
|
`secret vault create NAME` when `NAME` does, with "vault NAME already
|
||||||
|
exists", before writing anything. The check is in `vault.CreateVault`,
|
||||||
|
which both commands call while holding the state directory lock, so two
|
||||||
|
creates of one vault at once cannot both pass the check. Before, either
|
||||||
|
command replaced the vault's metadata, passphrase unlocker and
|
||||||
|
`longterm.age`, so none of its secrets could be decrypted any more. Both
|
||||||
|
commands now ask for the unlocker passphrase before creating the vault,
|
||||||
|
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 state directory lock
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/80). The test that each
|
||||||
|
changing command waits for the lock releases it as soon as it sees the
|
||||||
|
command waiting there, instead of after a fixed 100 ms. The two vaults
|
||||||
|
with passphrase unlockers that the path and move tests start from are
|
||||||
|
made once and copied for each test.
|
||||||
|
- 2026-10-04: `secret mv` rejects a move whose destination is the source
|
||||||
|
under another name, such as `foo` for `Foo` on a case-insensitive
|
||||||
|
filesystem (the macOS default) or a name reached through a symbolic
|
||||||
|
link, before changing anything, with or without `--force`, within a
|
||||||
|
vault and between vaults; before, `--force` removed the destination and
|
||||||
|
so deleted the secret. A rename that changes only letter case works on a
|
||||||
|
case-sensitive filesystem as before.
|
||||||
|
- 2026-10-04: Lint runs only in docker: `script/lint` builds
|
||||||
|
`Dockerfile.lint`, where golangci-lint is a build step rebuilt on
|
||||||
|
every run (`--no-cache-filter`), so an unchanged tree is linted too;
|
||||||
|
the module download stays cached. `script/bootstrap` no longer
|
||||||
|
installs golangci-lint, and the `Dockerfile` lint stage calls it
|
||||||
|
directly instead of `make lint`. `golangci-lint config verify` is not
|
||||||
|
run: it fetches its schema live over unpinned HTTPS.
|
||||||
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
|
- 2026-10-04: A PGP unlocker whose metadata has no usable GPG key ID
|
||||||
no longer panics: `GetID()` warns with the unlocker's directory and
|
no longer panics: `GetID()` warns with the unlocker's directory and
|
||||||
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
returns `pgp-unknown`. `ListUnlockers` skips, with a warning, an
|
||||||
@@ -49,25 +374,14 @@ Bring the repo into policy compliance in one commit:
|
|||||||
`current-unlocker` never go missing. New versions, new secrets and
|
`current-unlocker` never go missing. New versions, new secrets and
|
||||||
cross-vault copies are built in a temporary directory and renamed
|
cross-vault copies are built in a temporary directory and renamed
|
||||||
into place, and removals rename out of the way first, so a version
|
into place, and removals rename out of the way first, so a version
|
||||||
or secret is never half-added and never half-removed. An
|
or secret is never half-added and never half-removed.
|
||||||
interrupted command can still leave:
|
- 2026-10-03: The checks run before changing a vault now stop with an
|
||||||
- a broken unlocker, when it was replacing one: an unlocker added
|
error naming the path and cause when they cannot read what they
|
||||||
under the directory name of an existing one is rewritten file by
|
inspect, instead of reading the failure as "nothing there": the
|
||||||
file. That happens to a passphrase unlocker added to a vault that
|
duplicate check before `unlocker add pgp` (an unreadable
|
||||||
has one, and to a PGP, keychain or Secure Enclave unlocker added
|
`unlockers.d` or unlocker metadata file), the secret count that
|
||||||
on the same host and day as another of its type
|
guards removing the last unlocker and removing a vault, and the
|
||||||
(https://git.eeqj.de/sneak/secret/issues/71);
|
existing long-term key check before `vault import`.
|
||||||
- from `vault create` stopped at the passphrase prompt, a new vault
|
|
||||||
with no unlocker that is already the current vault; from `init`
|
|
||||||
stopped there, the default vault with no unlocker;
|
|
||||||
- from an unlocker add stopped before its metadata is written, a
|
|
||||||
directory that `unlocker list` warns about and `unlocker rm`
|
|
||||||
cannot remove;
|
|
||||||
- data under a `.tmp-` name in the state directory: a secret or
|
|
||||||
version being added, or the secret, version, unlocker or vault
|
|
||||||
being removed, encrypted keys included. Nothing deletes it; it
|
|
||||||
must be deleted by hand
|
|
||||||
(https://git.eeqj.de/sneak/secret/issues/75).
|
|
||||||
- 2026-10-03: `version rm`, `version promote` and `get --version`
|
- 2026-10-03: `version rm`, `version promote` and `get --version`
|
||||||
accept a version only if it is one of the versions `version list`
|
accept a version only if it is one of the versions `version list`
|
||||||
lists for that secret, compared as typed before any path is built
|
lists for that secret, compared as typed before any path is built
|
||||||
@@ -116,8 +430,15 @@ Bring the repo into policy compliance in one commit:
|
|||||||
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
`findUnlockerIDByMetadata` now returns an error so `unlocker list`
|
||||||
skips an unreadable `unlockers.d` entry with a warning instead of
|
skips an unreadable `unlockers.d` entry with a warning instead of
|
||||||
emitting a fabricated fallback ID.
|
emitting a fabricated fallback ID.
|
||||||
|
- 2026-08-07: Added `.editorconfig`
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/27).
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||||
Makefile shims, README Entrypoints section
|
Makefile shims, README Entrypoints section
|
||||||
|
- 2026-07-07: Added `REPO_POLICIES.md` and the `make hooks` target;
|
||||||
|
`.gitea/workflows/check.yml` now runs `script/cibuild`.
|
||||||
|
- 2026-03-30: Added the `make fmt-check` target and
|
||||||
|
`.gitea/workflows/check.yml`, which runs `docker build` on every push; the
|
||||||
|
`Dockerfile` base images are pinned by sha256.
|
||||||
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
- 2026-03-11: Secure Enclave unlocker for hardware-backed secret
|
||||||
protection, plus review fixes (stub panics, derivation index, tests,
|
protection, plus review fixes (stub panics, derivation index, tests,
|
||||||
README) on branch secure-enclave-unlocker.
|
README) on branch secure-enclave-unlocker.
|
||||||
@@ -139,53 +460,30 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Compliance (after Next Step lands): keep main green under the new
|
|
||||||
.gitea workflow; run make check before every merge.
|
|
||||||
- Implement version-number shell completion for the second arg of
|
- Implement version-number shell completion for the second arg of
|
||||||
`secret version promote` and `secret version rm`
|
`secret version promote` and `secret version rm`
|
||||||
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
(`internal/cli/version.go`; was an in-code TODO removed for godox).
|
||||||
- Cover mnemonic-vs-xprv identity consistency in
|
- Cover mnemonic-vs-xprv identity consistency in
|
||||||
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
|
`pkg/agehd/agehd_test.go` `TestMnemonicVsXPRVConsistency` (was an
|
||||||
in-code FIXME removed for godox).
|
in-code FIXME removed for godox).
|
||||||
- Darwin-gated files (`internal/secret/keychainunlocker.go`,
|
- CI does not compile, lint or test the files built only with cgo on
|
||||||
`seunlocker_darwin.go`, `internal/macse/macse_darwin.go`, related
|
macOS, since compiling them needs Apple's SDK:
|
||||||
tests) are not linted on the Linux CI runner and still contain lines
|
`internal/secret/keychainunlocker_cgo.go` (the three functions that call
|
||||||
over the new 88-column limit; they will surface if lint ever runs on
|
`go-keychain`) with `keychainunlocker_test.go`, and `internal/macse`
|
||||||
macOS.
|
(`macse_darwin.go`, `macse_test.go`, the Objective-C sources). Lint has
|
||||||
- Merge secure-enclave-unlocker to main once review is done.
|
never run on them, so it would likely find more there than the line
|
||||||
|
lengths. No macOS test runs in CI. A macOS runner would cover all of it
|
||||||
|
(asked on https://git.eeqj.de/sneak/secret/issues/50).
|
||||||
- 1.0 critical security blockers (from repo TODO.md):
|
- 1.0 critical security blockers (from repo TODO.md):
|
||||||
- Command injection: GPG key IDs passed unescaped to exec.Command
|
- Memory security: age writes an identity's private key out as a string in
|
||||||
(pgpunlocker.go:323-327); data.String() passed unescaped to the
|
ordinary memory, and the copies it makes on the way stay there
|
||||||
security command (keychainunlocker.go:472-476).
|
(`secret.IdentityToLockedBuffer` overwrites only the string itself).
|
||||||
- Memory security: age identity .String() creates unprotected
|
|
||||||
copies (keychainunlocker.go:356, pgpunlocker.go:256,
|
|
||||||
version.go:155); age secret key held in a plain string in
|
|
||||||
cli/crypto.go:86,91,113; private keys exposed via buffer.Bytes()
|
|
||||||
to GPGEncryptFunc and EncryptWithPassphrase.
|
|
||||||
- Input validation: no maximum secret size (DoS).
|
|
||||||
- Timing attacks: bytes.Equal passphrase compare (cli/init.go:
|
|
||||||
209-216); non-constant-time public key compare (vault.go:95-100).
|
|
||||||
- High priority:
|
|
||||||
- Secure temporary file handling and cleanup.
|
|
||||||
- Print cobra usage only for argument errors, not internal
|
|
||||||
failures.
|
|
||||||
- Initialize a default unlock key at vault creation.
|
|
||||||
- Confirmation prompts for destructive operations (keys rm, vault
|
|
||||||
deletion).
|
|
||||||
- Add secret rm and vault deletion commands.
|
|
||||||
- Medium priority:
|
- Medium priority:
|
||||||
- Standardize error messages; stop leaking internals.
|
- Standardize error messages; stop leaking internals.
|
||||||
- Graceful handling of corrupted or missing key files with recovery
|
|
||||||
suggestions.
|
|
||||||
- Validate GPG key existence before creating PGP unlock keys.
|
|
||||||
- Split oversized CLI functions.
|
- Split oversized CLI functions.
|
||||||
- Document env var security (SB_UNLOCK_PASSPHRASE,
|
|
||||||
SB_SECRET_MNEMONIC); clear after use.
|
|
||||||
- mlock/munlock for sensitive allocations.
|
|
||||||
- Cleanups: read statedir from environment or default instead of
|
- Cleanups: read statedir from environment or default instead of
|
||||||
passing it around.
|
passing it around.
|
||||||
- Enhancements: help examples, shell completion, colored output,
|
- Enhancements: help examples, colored output, --quiet flag, name suggestions on
|
||||||
--quiet flag, name suggestions on miss, audit logging, hardware
|
miss, audit logging, hardware integration tests (Keychain, GPG), naming
|
||||||
integration tests (Keychain, GPG), naming consistency, vault
|
consistency, vault export/import, batch operations, search, secret metadata
|
||||||
export/import, batch operations, search, secret metadata
|
|
||||||
(descriptions, tags).
|
(descriptions, tags).
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ require (
|
|||||||
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
github.com/btcsuite/btcd/btcec/v2 v2.1.3
|
||||||
github.com/btcsuite/btcd/btcutil v1.1.6
|
github.com/btcsuite/btcd/btcutil v1.1.6
|
||||||
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d
|
||||||
|
github.com/creack/pty v1.1.24
|
||||||
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
github.com/keybase/go-keychain v0.0.0-20230307172405-3e4884637dd1
|
||||||
github.com/oklog/ulid/v2 v2.1.1
|
github.com/oklog/ulid/v2 v2.1.1
|
||||||
github.com/spf13/afero v1.14.0
|
github.com/spf13/afero v1.14.0
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg
|
|||||||
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY=
|
||||||
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
|||||||
+50
-3
@@ -3,8 +3,11 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -14,6 +17,15 @@ type Instance struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
stateDir string
|
stateDir string
|
||||||
cmd *cobra.Command
|
cmd *cobra.Command
|
||||||
|
// Mnemonic and UnlockPassphrase hold the values of SB_SECRET_MNEMONIC
|
||||||
|
// and SB_UNLOCK_PASSPHRASE that readSecretEnv read, or nil when it found
|
||||||
|
// none.
|
||||||
|
Mnemonic *memguard.LockedBuffer
|
||||||
|
UnlockPassphrase *memguard.LockedBuffer
|
||||||
|
// terminal, when set, stands in for the terminal that confirm reads
|
||||||
|
// the user's answer from; only tests set it. When it is nil, confirm
|
||||||
|
// reads stdin, and only when stdin is a terminal.
|
||||||
|
terminal io.Reader
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCLIInstance creates a new CLI instance with the real filesystem
|
// NewCLIInstance creates a new CLI instance with the real filesystem
|
||||||
@@ -69,7 +81,42 @@ func (cli *Instance) GetStateDir() string {
|
|||||||
return cli.stateDir
|
return cli.stateDir
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print outputs to the command's configured output writer
|
// readSecretEnv reads SB_SECRET_MNEMONIC into cli.Mnemonic and
|
||||||
func (cli *Instance) Print(a ...any) (int, error) {
|
// SB_UNLOCK_PASSPHRASE into cli.UnlockPassphrase. A command that may need
|
||||||
return fmt.Fprint(cli.cmd.OutOrStdout(), a...)
|
// either calls it once, before anything else, and passes the buffers on
|
||||||
|
// from there: each variable is unset as soon as it is read, so that the
|
||||||
|
// processes this one starts, gpg among them, do not inherit it, and a
|
||||||
|
// second read would find nothing. The returned function destroys both
|
||||||
|
// buffers.
|
||||||
|
func (cli *Instance) readSecretEnv() func() {
|
||||||
|
cli.Mnemonic = readAndUnsetEnv(secret.EnvMnemonic)
|
||||||
|
cli.UnlockPassphrase = readAndUnsetEnv(secret.EnvUnlockPassphrase)
|
||||||
|
|
||||||
|
mnemonic, passphrase := cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
if mnemonic != nil {
|
||||||
|
mnemonic.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
if passphrase != nil {
|
||||||
|
passphrase.Destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAndUnsetEnv returns the value of the environment variable name in a
|
||||||
|
// locked buffer, or nil when it is unset or empty, and unsets the variable.
|
||||||
|
// Unsetting does not erase the value: it stays in this process's memory,
|
||||||
|
// and in /proc/<pid>/environ, which shows the environment the process
|
||||||
|
// started with. The caller must destroy the returned buffer.
|
||||||
|
func readAndUnsetEnv(name string) *memguard.LockedBuffer {
|
||||||
|
value := os.Getenv(name)
|
||||||
|
_ = os.Unsetenv(name)
|
||||||
|
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return memguard.NewBufferFromBytes([]byte(value))
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-30
@@ -1,10 +1,10 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"maps"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -44,7 +44,7 @@ func getSecretNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
// getUnlockerIDsCompletionFunc returns a completion function that provides
|
||||||
// unlocker IDs
|
// unlocker IDs, the names of the unlockers' directories in unlockers.d
|
||||||
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
) ([]string, cobra.ShellCompDirective) {
|
) ([]string, cobra.ShellCompDirective) {
|
||||||
@@ -57,38 +57,15 @@ func getUnlockerIDsCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get unlocker metadata list
|
unlockerMetadata, err := vlt.ListUnlockers()
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vault directory
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect unlocker IDs
|
|
||||||
var completions []string
|
var completions []string
|
||||||
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
for _, id := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||||
|
if strings.HasPrefix(id, toComplete) {
|
||||||
for _, metadata := range unlockerMetadataList {
|
|
||||||
// Get the actual unlocker ID by creating the unlocker instance
|
|
||||||
id, err := findUnlockerIDByMetadata(
|
|
||||||
fs, unlockersDir, metadata, false,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn(
|
|
||||||
"Could not read unlockers directory during completion, "+
|
|
||||||
"skipping unlocker",
|
|
||||||
"unlockers_dir", unlockersDir, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if id != "" && strings.HasPrefix(id, toComplete) {
|
|
||||||
completions = append(completions, id)
|
completions = append(completions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,7 +100,9 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||||
// colon is present in the input
|
// colon is present in the input. It completes nothing when the vault part
|
||||||
|
// is not a valid vault name, so that a name such as ".." cannot list a
|
||||||
|
// directory outside vaults.d.
|
||||||
func completeVaultQualifiedSecrets(
|
func completeVaultQualifiedSecrets(
|
||||||
fs afero.Fs, stateDir, toComplete string,
|
fs afero.Fs, stateDir, toComplete string,
|
||||||
) []string {
|
) []string {
|
||||||
@@ -134,6 +113,10 @@ func completeVaultQualifiedSecrets(
|
|||||||
vaultName := parts[0]
|
vaultName := parts[0]
|
||||||
secretPrefix := parts[1]
|
secretPrefix := parts[1]
|
||||||
|
|
||||||
|
if vault.ValidateVaultName(vaultName) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||||
|
|
||||||
secrets, err := vlt.ListSecrets()
|
secrets, err := vlt.ListSecrets()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestVaultSecretCompletionRejectsInvalidVaultName is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/68: completing a `vault:secret`
|
||||||
|
// argument lists nothing when the vault part is not a valid vault name, even
|
||||||
|
// where that name, joined onto vaults.d, leads to a secrets.d directory.
|
||||||
|
func TestVaultSecretCompletionRejectsInvalidVaultName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateDir = "/state"
|
||||||
|
dirPerm = 0o700
|
||||||
|
)
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// The vault "work" holds the secret "x". So does every directory an
|
||||||
|
// invalid name below would lead to from vaults.d.
|
||||||
|
for _, vaultName := range []string{"work", ".", "..", "a/b"} {
|
||||||
|
secretDir := filepath.Join(stateDir, "vaults.d", vaultName, "secrets.d", "x")
|
||||||
|
require.NoError(t, fs.MkdirAll(secretDir, dirPerm))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"work:x"},
|
||||||
|
completeVaultQualifiedSecrets(fs, stateDir, "work:"))
|
||||||
|
|
||||||
|
for _, toComplete := range []string{".:", "..:", "a/b:"} {
|
||||||
|
assert.Empty(t, completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
||||||
|
"completing %q", toComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"golang.org/x/term"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Sentinel errors for asking the user to confirm a removal
|
||||||
|
var (
|
||||||
|
errNoTerminal = errors.New("stdin is not a terminal, so there is " +
|
||||||
|
"nobody to ask for confirmation; pass --force to remove without asking")
|
||||||
|
errNotConfirmed = errors.New("cancelled; nothing was removed")
|
||||||
|
errChangedWhileAsking = errors.New("what was to be removed changed " +
|
||||||
|
"while waiting for the answer; nothing was removed")
|
||||||
|
)
|
||||||
|
|
||||||
|
// askThenLock asks the user to confirm a removal, unless force is set, and
|
||||||
|
// then takes the state directory lock and returns the function that
|
||||||
|
// releases it. find makes the command's checks, keeps what it found for
|
||||||
|
// the caller to remove, and returns the question that names it. find runs
|
||||||
|
// before the question, which is asked without the lock so that no other
|
||||||
|
// command waits while the user answers, and runs again once the lock is
|
||||||
|
// taken. That run is the last, so the caller removes what find found under
|
||||||
|
// the lock. If its question then differs from the one the user answered,
|
||||||
|
// something changed in between, and askThenLock fails.
|
||||||
|
func (cli *Instance) askThenLock(
|
||||||
|
cmd *cobra.Command, force bool, find func() (string, error),
|
||||||
|
) (func(), error) {
|
||||||
|
asked := ""
|
||||||
|
|
||||||
|
if !force {
|
||||||
|
question, err := find()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cli.confirm(cmd, question)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
asked = question
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
question, err := find()
|
||||||
|
if err == nil && !force && question != asked {
|
||||||
|
err = errChangedWhileAsking
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return release, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm asks question and returns nil only when the user answers y or
|
||||||
|
// yes; any other answer, a bare Enter included, cancels. When stdin is not
|
||||||
|
// a terminal it asks nothing and fails at once: nobody is there to answer,
|
||||||
|
// and waiting for an answer would hang a script. Stdin decides, not
|
||||||
|
// stdout, because the answer is read from stdin: `secret rm foo | tee log`
|
||||||
|
// still asks. The question goes to stderr.
|
||||||
|
func (cli *Instance) confirm(cmd *cobra.Command, question string) error {
|
||||||
|
answers := cli.terminal
|
||||||
|
if answers == nil {
|
||||||
|
answers = cmd.InOrStdin()
|
||||||
|
|
||||||
|
if !isTerminal(answers) {
|
||||||
|
return errNoTerminal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s [y/N] ", question)
|
||||||
|
|
||||||
|
answer, err := bufio.NewReader(answers).ReadString('\n')
|
||||||
|
if err != nil && !errors.Is(err, io.EOF) {
|
||||||
|
return fmt.Errorf("failed to read the answer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch strings.ToLower(strings.TrimSpace(answer)) {
|
||||||
|
case "y", "yes":
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return errNotConfirmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isTerminal reports whether r is a terminal.
|
||||||
|
func isTerminal(r io.Reader) bool {
|
||||||
|
file, ok := r.(*os.File)
|
||||||
|
|
||||||
|
return ok && term.IsTerminal(int(file.Fd()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
// Confirmation Tests
|
||||||
|
//
|
||||||
|
// `secret rm`, `secret version rm`, `secret vault remove` and
|
||||||
|
// `secret unlocker remove` ask the user to confirm on a terminal, naming
|
||||||
|
// what they are about to remove, and remove it only on y or yes. --force
|
||||||
|
// skips the question. Without --force, a command whose stdin is not a
|
||||||
|
// terminal fails at once, since nobody is there to answer.
|
||||||
|
//
|
||||||
|
// The tests answer through Instance.terminal, which stands in for a
|
||||||
|
// terminal. Without it, whether stdin is a terminal decides; the tests in
|
||||||
|
// integration_test.go that run `secret rm` on a pseudo-terminal cover that.
|
||||||
|
|
||||||
|
//nolint:testpackage // sets the unexported terminal field of Instance
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// confirmTestSecret is the secret the tests remove, or remove a
|
||||||
|
// version of, in the vault "work".
|
||||||
|
confirmTestSecret = "test/secret"
|
||||||
|
|
||||||
|
// lastUnlockerRemoval names the case that removes the only unlocker.
|
||||||
|
lastUnlockerRemoval = "unlocker rm, the last one"
|
||||||
|
)
|
||||||
|
|
||||||
|
// removal is one removal command, set up on its own state directory.
|
||||||
|
type removal struct {
|
||||||
|
fs afero.Fs
|
||||||
|
run func(cli *Instance, cmd *cobra.Command, force bool) error
|
||||||
|
// removed is the directory the command removes.
|
||||||
|
removed string
|
||||||
|
// question is the question the command asks.
|
||||||
|
question string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfirmTestVaults returns an in-memory state directory with the
|
||||||
|
// vaults "other" and "work", the current one. "work" holds two versions of
|
||||||
|
// confirmTestSecret and the given number of PGP unlockers. It returns the
|
||||||
|
// directory of "work" and the older version.
|
||||||
|
func newConfirmTestVaults(
|
||||||
|
t *testing.T, unlockers int,
|
||||||
|
) (*afero.MemMapFs, string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs := &afero.MemMapFs{}
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
addTestSecret(t, vlt, []byte("older"), false)
|
||||||
|
addTestSecret(t, vlt, []byte("newer"), true)
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
versions, err := secret.ListVersions(fs,
|
||||||
|
filepath.Join(vaultDir, "secrets.d", "test%secret"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, versions, 2)
|
||||||
|
|
||||||
|
for i := range unlockers {
|
||||||
|
writePGPUnlocker(t, fs, filepath.Join(vaultDir, "unlockers.d"),
|
||||||
|
fmt.Sprintf("pgp-%d", i),
|
||||||
|
time.Date(2026, time.October, 4, 12, i, 0, 0, time.UTC),
|
||||||
|
listTestGPGKeyID+string(rune('A'+i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListVersions lists the newest version first.
|
||||||
|
return fs, vaultDir, versions[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRemoval sets up the removal the command names.
|
||||||
|
func newRemoval(t *testing.T, command string) removal {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
unlockers := 2
|
||||||
|
if command == lastUnlockerRemoval {
|
||||||
|
unlockers = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fs, workDir, older := newConfirmTestVaults(t, unlockers)
|
||||||
|
// The first unlocker's directory name, written by newConfirmTestVaults
|
||||||
|
unlockerID := "pgp-0"
|
||||||
|
|
||||||
|
removeFirstUnlocker := func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||||
|
return cli.UnlockersRemove(unlockerID, force, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch command {
|
||||||
|
case "rm":
|
||||||
|
return removal{
|
||||||
|
fs: fs,
|
||||||
|
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||||
|
return cli.RemoveSecret(cmd, confirmTestSecret, force)
|
||||||
|
},
|
||||||
|
removed: filepath.Join(workDir, "secrets.d", "test%secret"),
|
||||||
|
question: "Permanently remove secret 'test/secret' and its 2 " +
|
||||||
|
"version(s) from vault 'work'?",
|
||||||
|
}
|
||||||
|
case "version rm":
|
||||||
|
return removal{
|
||||||
|
fs: fs,
|
||||||
|
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||||
|
return cli.RemoveVersion(cmd, confirmTestSecret, older, force)
|
||||||
|
},
|
||||||
|
removed: filepath.Join(
|
||||||
|
workDir, "secrets.d", "test%secret", "versions", older),
|
||||||
|
question: "Permanently remove version " + older +
|
||||||
|
" of secret 'test/secret' from vault 'work'?",
|
||||||
|
}
|
||||||
|
case "vault rm":
|
||||||
|
return removal{
|
||||||
|
fs: fs,
|
||||||
|
run: func(cli *Instance, cmd *cobra.Command, force bool) error {
|
||||||
|
return cli.RemoveVault(cmd, "work", force)
|
||||||
|
},
|
||||||
|
removed: workDir,
|
||||||
|
question: "Permanently remove vault 'work' and its 1 secret(s)?",
|
||||||
|
}
|
||||||
|
case "unlocker rm":
|
||||||
|
return removal{
|
||||||
|
fs: fs,
|
||||||
|
run: removeFirstUnlocker,
|
||||||
|
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
||||||
|
question: "Permanently remove unlocker '" + unlockerID +
|
||||||
|
"' from vault 'work'? It is not the vault's last unlocker.",
|
||||||
|
}
|
||||||
|
case lastUnlockerRemoval:
|
||||||
|
return removal{
|
||||||
|
fs: fs,
|
||||||
|
run: removeFirstUnlocker,
|
||||||
|
removed: filepath.Join(workDir, "unlockers.d", unlockerID),
|
||||||
|
question: "Permanently remove unlocker '" + unlockerID +
|
||||||
|
"', the last unlocker of vault 'work', which holds 1 " +
|
||||||
|
"secret(s)? Without an unlocker the vault opens only " +
|
||||||
|
"with its mnemonic.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("no removal %q", command)
|
||||||
|
|
||||||
|
return removal{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// removalCommands lists the commands newRemoval sets up.
|
||||||
|
func removalCommands() []string {
|
||||||
|
return []string{
|
||||||
|
"rm", "version rm", "vault rm", "unlocker rm", lastUnlockerRemoval,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newConfirmTestCommand returns a command whose output is discarded and
|
||||||
|
// whose stderr, where the question goes, is the returned buffer.
|
||||||
|
func newConfirmTestCommand() (*cobra.Command, *bytes.Buffer) {
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(&stderr)
|
||||||
|
|
||||||
|
return cmd, &stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireExists asserts whether the directory dir exists.
|
||||||
|
func requireExists(t *testing.T, fs afero.Fs, dir string, want bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(fs, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, want, exists, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConfirmAnswers checks which answers confirm accepts: y or yes, in
|
||||||
|
// any case, around which spaces do not matter.
|
||||||
|
func TestConfirmAnswers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for answer, want := range map[string]error{
|
||||||
|
"y\n": nil,
|
||||||
|
"Y\n": nil,
|
||||||
|
"yes\n": nil,
|
||||||
|
" YES \n": nil,
|
||||||
|
"y": nil,
|
||||||
|
"\n": errNotConfirmed,
|
||||||
|
"": errNotConfirmed,
|
||||||
|
"n\n": errNotConfirmed,
|
||||||
|
"yy\n": errNotConfirmed,
|
||||||
|
"no\ny\n": errNotConfirmed,
|
||||||
|
} {
|
||||||
|
t.Run(fmt.Sprintf("%q", answer), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cli := &Instance{terminal: strings.NewReader(answer)}
|
||||||
|
cmd, stderr := newConfirmTestCommand()
|
||||||
|
|
||||||
|
err := cli.confirm(cmd, "Remove it?")
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, want)
|
||||||
|
assert.Equal(t, "Remove it? [y/N] ", stderr.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemovalAnsweredYesRemoves checks that each removal asks its question
|
||||||
|
// and removes what it names when the user answers y.
|
||||||
|
func TestRemovalAnsweredYesRemoves(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range removalCommands() {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRemoval(t, command)
|
||||||
|
requireExists(t, r.fs, r.removed, true)
|
||||||
|
|
||||||
|
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||||
|
cli.terminal = strings.NewReader("y\n")
|
||||||
|
cmd, stderr := newConfirmTestCommand()
|
||||||
|
|
||||||
|
require.NoError(t, r.run(cli, cmd, false))
|
||||||
|
|
||||||
|
assert.Equal(t, r.question+" [y/N] ", stderr.String())
|
||||||
|
requireExists(t, r.fs, r.removed, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemovalDeclinedLeavesEverything checks that each removal changes
|
||||||
|
// nothing when the user answers anything but y or yes, a bare Enter
|
||||||
|
// included.
|
||||||
|
func TestRemovalDeclinedLeavesEverything(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range removalCommands() {
|
||||||
|
for _, answer := range []string{"\n", "n\n", ""} {
|
||||||
|
t.Run(fmt.Sprintf("%s %q", command, answer), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRemoval(t, command)
|
||||||
|
before := stateDirModTimes(t, r.fs)
|
||||||
|
|
||||||
|
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||||
|
cli.terminal = strings.NewReader(answer)
|
||||||
|
cmd, stderr := newConfirmTestCommand()
|
||||||
|
|
||||||
|
err := r.run(cli, cmd, false)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, errNotConfirmed)
|
||||||
|
assert.Equal(t, r.question+" [y/N] ", stderr.String())
|
||||||
|
assert.Equal(t, before, stateDirModTimes(t, r.fs))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemovalForcedAsksNothing checks that each removal with --force
|
||||||
|
// removes what it would have named without asking, and without reading
|
||||||
|
// its input, which is not a terminal.
|
||||||
|
func TestRemovalForcedAsksNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range removalCommands() {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRemoval(t, command)
|
||||||
|
|
||||||
|
input := strings.NewReader("n\n")
|
||||||
|
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||||
|
cmd, stderr := newConfirmTestCommand()
|
||||||
|
cmd.SetIn(input)
|
||||||
|
|
||||||
|
require.NoError(t, r.run(cli, cmd, true))
|
||||||
|
|
||||||
|
assert.Empty(t, stderr.String(), "asked with --force")
|
||||||
|
assert.Equal(t, 2, input.Len(), "read its input with --force")
|
||||||
|
requireExists(t, r.fs, r.removed, false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemovalWithoutTerminalFailsAtOnce checks that each removal without
|
||||||
|
// --force, whose input is not a terminal, fails at once telling the user
|
||||||
|
// to pass --force, and changes nothing. The input is a pipe that nobody
|
||||||
|
// writes to or closes, so reading it would block for good.
|
||||||
|
func TestRemovalWithoutTerminalFailsAtOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, command := range removalCommands() {
|
||||||
|
t.Run(command, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRemoval(t, command)
|
||||||
|
before := stateDirModTimes(t, r.fs)
|
||||||
|
|
||||||
|
input, inputWriter, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = inputWriter.Close()
|
||||||
|
_ = input.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
cli := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||||
|
cmd, stderr := newConfirmTestCommand()
|
||||||
|
cmd.SetIn(input)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() { done <- r.run(cli, cmd, false) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.ErrorIs(t, err, errNoTerminal)
|
||||||
|
assert.Contains(t, err.Error(), "pass --force")
|
||||||
|
case <-time.After(lockWait):
|
||||||
|
// Closing the pipe ends the read, and frees the lock if
|
||||||
|
// the command holds it.
|
||||||
|
_ = inputWriter.Close()
|
||||||
|
|
||||||
|
t.Fatal("waited for an answer on input that is not a terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Empty(t, stderr.String(), "asked without a terminal")
|
||||||
|
assert.Equal(t, before, stateDirModTimes(t, r.fs))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemovalAsksWithoutHoldingLock checks that while `secret rm` waits
|
||||||
|
// for its answer, another command can take the state directory lock and
|
||||||
|
// change the secret, and that the removal then removes nothing, since the
|
||||||
|
// secret is no longer what the question named.
|
||||||
|
func TestRemovalAsksWithoutHoldingLock(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRemoval(t, "rm")
|
||||||
|
|
||||||
|
answers, answerWriter := io.Pipe()
|
||||||
|
questions, questionWriter := io.Pipe()
|
||||||
|
|
||||||
|
// Closing the answers ends the read if the test fails while the
|
||||||
|
// command waits for one.
|
||||||
|
t.Cleanup(func() { _ = answerWriter.Close() })
|
||||||
|
|
||||||
|
rm := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||||
|
rm.terminal = answers
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(questionWriter)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() { done <- r.run(rm, cmd, false) }()
|
||||||
|
|
||||||
|
question, err := bufio.NewReader(questions).ReadString(']')
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, r.question+" [y/N]", question)
|
||||||
|
|
||||||
|
// Adds a third version while rm waits for its answer.
|
||||||
|
add := NewCLIInstanceWithStateDir(r.fs, testStateDir)
|
||||||
|
add.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
add.cmd = &cobra.Command{}
|
||||||
|
add.cmd.SetIn(strings.NewReader("newest"))
|
||||||
|
add.cmd.SetOut(io.Discard)
|
||||||
|
|
||||||
|
added := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() { added <- add.AddSecret(confirmTestSecret, true) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-added:
|
||||||
|
require.NoError(t, err)
|
||||||
|
case <-time.After(lockWait):
|
||||||
|
t.Fatal("secret add waited for the lock while secret rm asked")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = answerWriter.Write([]byte("y\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
require.ErrorIs(t, err, errChangedWhileAsking)
|
||||||
|
case <-time.After(lockWait):
|
||||||
|
t.Fatal("secret rm did not finish once answered")
|
||||||
|
}
|
||||||
|
|
||||||
|
requireExists(t, r.fs, r.removed, true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCreateExistingVaultChangesNothing is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/74, where running `secret init`
|
||||||
|
// a second time, or `secret vault create` with the name of an existing
|
||||||
|
// vault, replaced that vault's keys, so that none of its secrets could be
|
||||||
|
// decrypted any more. Each must refuse, change nothing, and leave every
|
||||||
|
// vault's secret readable through its passphrase unlocker.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // the cases share cmd
|
||||||
|
func TestCreateExistingVaultChangesNothing(t *testing.T) {
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
// newCLI returns an instance on fs given the mnemonic and the unlock
|
||||||
|
// passphrase, as from the environment
|
||||||
|
newCLI := func(fs afero.Fs) *cli.Instance {
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
c.Mnemonic = mnemonic
|
||||||
|
c.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// `secret init`, `secret vault create work`, `secret vault select
|
||||||
|
// default`, and the secret "x" in each vault. "work" is then not the
|
||||||
|
// current vault, which creating it again must not change.
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
c := newCLI(fs)
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
|
require.NoError(t, c.Init(cmd))
|
||||||
|
require.NoError(t, c.CreateVault(cmd, "work"))
|
||||||
|
require.NoError(t, c.SelectVault(cmd, "default"))
|
||||||
|
|
||||||
|
vaults, err := vault.ListVaults(fs, testStateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, vaults, 2)
|
||||||
|
|
||||||
|
for _, name := range vaults {
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||||
|
err := vault.NewVault(fs, testStateDir, name).AddSecret("x", value, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := snapshotStateDir(t, fs)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
run func(c *cli.Instance) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"init",
|
||||||
|
func(c *cli.Instance) error { return c.Init(cmd) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vault create default",
|
||||||
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "default") },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vault create work",
|
||||||
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
fs := newFsFromSnapshot(t, before)
|
||||||
|
|
||||||
|
err := tt.run(newCLI(fs))
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, vault.ErrVaultExists)
|
||||||
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every case left the state directory exactly as recorded in before, so
|
||||||
|
// reading each vault's secret once from it shows that it still decrypts
|
||||||
|
// after each case. Without the mnemonic, reading a secret goes through
|
||||||
|
// the vault's passphrase unlocker, which is slow.
|
||||||
|
for _, name := range vaults {
|
||||||
|
vlt := vault.NewVault(fs, testStateDir, name)
|
||||||
|
vlt.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
value, err := vlt.GetSecret("x")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
unchanged := bytes.Equal([]byte("value"), value.Bytes())
|
||||||
|
value.Destroy()
|
||||||
|
|
||||||
|
require.True(t, unchanged, "vault %q kept its secret", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVaultCreationLeavesNoSecretInEnvironment is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/60, where `secret init` and
|
||||||
|
// `secret vault create` put the mnemonic into the process environment,
|
||||||
|
// which every program they ran inherited, and SB_SECRET_MNEMONIC and
|
||||||
|
// SB_UNLOCK_PASSPHRASE were never unset. Each command, given both, must
|
||||||
|
// leave neither in the environment.
|
||||||
|
func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
||||||
|
t.Setenv(secret.EnvStateDir, t.TempDir())
|
||||||
|
|
||||||
|
run := func(args ...string) {
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
|
// With no terminal to prompt on, this succeeds only if the command
|
||||||
|
// read both variables
|
||||||
|
_, err := cli.ExecuteCommandInProcess(args, "", nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, name := range []string{secret.EnvMnemonic, secret.EnvUnlockPassphrase} {
|
||||||
|
_, set := os.LookupEnv(name)
|
||||||
|
require.False(t, set, "%s is set after %v", name, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run("init")
|
||||||
|
run("vault", "create", "work")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
||||||
|
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
|
||||||
|
// `secret vault create` stopped at the passphrase prompt left a vault with
|
||||||
|
// no unlocker, which neither command would then create again. Each must ask
|
||||||
|
// for the passphrase before writing anything.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // the cases share cmd
|
||||||
|
func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
// An empty state directory for `secret init`, and one holding the vault
|
||||||
|
// "default" for `secret vault create work`.
|
||||||
|
empty := afero.NewMemMapFs()
|
||||||
|
require.NoError(t, empty.MkdirAll(testStateDir, secret.DirPerms))
|
||||||
|
|
||||||
|
withDefault := afero.NewMemMapFs()
|
||||||
|
_, err := vault.CreateVault(withDefault, testStateDir, "default", mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
fs afero.Fs
|
||||||
|
run func(c *cli.Instance) error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"init",
|
||||||
|
empty,
|
||||||
|
func(c *cli.Instance) error { return c.Init(cmd) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"vault create work",
|
||||||
|
withDefault,
|
||||||
|
func(c *cli.Instance) error { return c.CreateVault(cmd, "work") },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
before := snapshotStateDir(t, tt.fs)
|
||||||
|
|
||||||
|
// Given no unlock passphrase, both commands prompt for it, which
|
||||||
|
// fails because the tests do not run in a terminal.
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(tt.fs, testStateDir)
|
||||||
|
c.Mnemonic = mnemonic
|
||||||
|
|
||||||
|
err := tt.run(c)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, secret.ErrPassphraseNotRead)
|
||||||
|
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)
|
||||||
|
}
|
||||||
+11
-23
@@ -17,7 +17,6 @@ import (
|
|||||||
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
|
||||||
@@ -41,6 +40,9 @@ func newCryptoCmd(
|
|||||||
|
|
||||||
cli.cmd = cmd
|
cli.cmd = cmd
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return run(cli, args[0], inputFile, outputFile)
|
return run(cli, args[0], inputFile, outputFile)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -88,8 +90,7 @@ func (cli *Instance) storeNewEncryptionKey(
|
|||||||
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
return nil, fmt.Errorf("failed to generate age key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the generated key directly in a secure buffer
|
secureBuffer := secret.IdentityToLockedBuffer(identity)
|
||||||
secureBuffer := memguard.NewBufferFromBytes([]byte(identity.String()))
|
|
||||||
|
|
||||||
err = vlt.AddSecret(secretName, secureBuffer, false)
|
err = vlt.AddSecret(secretName, secureBuffer, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -128,7 +129,7 @@ func (cli *Instance) resolveEncryptionKey(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Secret exists, get the age secret key from it
|
// Secret exists, get the age secret key from it
|
||||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
secretBuffer, err := vlt.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
return nil, fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
@@ -156,6 +157,8 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Get or create the age secret key for this secret
|
// Get or create the age secret key for this secret
|
||||||
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
keyBuffer, err := cli.resolveEncryptionKey(vlt, secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -230,6 +233,8 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
secretObj := secret.NewSecret(vlt, secretName)
|
secretObj := secret.NewSecret(vlt, secretName)
|
||||||
|
|
||||||
@@ -239,11 +244,11 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretDoesNotExist)
|
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the age secret key from the secret
|
// Get the age secret key from the secret
|
||||||
secretBuffer, err := cli.getSecretValue(vlt, secretObj)
|
secretBuffer, err := vlt.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get secret value: %w", err)
|
return fmt.Errorf("failed to get secret value: %w", err)
|
||||||
}
|
}
|
||||||
@@ -307,20 +312,3 @@ func isValidAgeSecretKey(key string) bool {
|
|||||||
|
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSecretValue retrieves the value of a secret using the appropriate
|
|
||||||
// unlocker
|
|
||||||
func (cli *Instance) getSecretValue(
|
|
||||||
vlt *vault.Vault, secretObj *secret.Secret,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
if os.Getenv(secret.EnvMnemonic) != "" {
|
|
||||||
return secretObj.GetValue(nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlocker, err := vlt.GetCurrentUnlocker()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return secretObj.GetValue(unlocker)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,7 @@ 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")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,6 +72,9 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
return cli.GenerateSecret(cmd, args[0], length, secretType, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -145,7 +144,8 @@ func (cli *Instance) GenerateSecret(
|
|||||||
case "alnum":
|
case "alnum":
|
||||||
secretValue, err = generateRandomAlnum(length)
|
secretValue, err = generateRandomAlnum(length)
|
||||||
case "mnemonic":
|
case "mnemonic":
|
||||||
return errMnemonicTypeNotSupported
|
return fmt.Errorf("%w: mnemonic (use 'secret generate mnemonic' instead)",
|
||||||
|
errUnsupportedSecretType)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
return fmt.Errorf("%w: %s (supported: base58, alnum)",
|
||||||
errUnsupportedSecretType, secretType)
|
errUnsupportedSecretType, secretType)
|
||||||
@@ -167,6 +167,8 @@ func (cli *Instance) GenerateSecret(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Protect the generated secret immediately
|
// Protect the generated secret immediately
|
||||||
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
secretBuffer := memguard.NewBufferFromBytes([]byte(secretValue))
|
||||||
defer secretBuffer.Destroy()
|
defer secretBuffer.Destroy()
|
||||||
@@ -199,8 +201,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 <= 0 {
|
if length < 1 {
|
||||||
return "", errLengthNotPositive
|
return "", errLengthTooSmall
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]byte, length)
|
result := make([]byte, length)
|
||||||
|
|||||||
+35
-76
@@ -6,13 +6,10 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"filippo.io/age"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/tyler-smith/go-bip39"
|
"github.com/tyler-smith/go-bip39"
|
||||||
@@ -39,68 +36,35 @@ func RunInit(cmd *cobra.Command, _ []string) error {
|
|||||||
log.Fatalf("failed to initialize CLI: %v", err)
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.Init(cmd)
|
return cli.Init(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// promptMnemonic reads the mnemonic from the environment or interactively.
|
// promptMnemonic returns the mnemonic from the environment, cli.Mnemonic,
|
||||||
// The returned cleanup function must be deferred by the caller.
|
// or reads it interactively. The returned cleanup function must be deferred
|
||||||
func promptMnemonic() (string, func(), error) {
|
// by the caller.
|
||||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
||||||
|
if cli.Mnemonic != nil {
|
||||||
secret.Debug("Using mnemonic from environment variable")
|
secret.Debug("Using mnemonic from environment variable")
|
||||||
|
|
||||||
return envMnemonic, func() {}, nil
|
return cli.Mnemonic, func() {}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Prompting user for mnemonic phrase")
|
secret.Debug("Prompting user for mnemonic phrase")
|
||||||
|
|
||||||
// Read mnemonic securely without echo
|
// Read mnemonic securely without echo
|
||||||
mnemonicBuffer, err := secret.ReadPassphrase("Enter your BIP39 mnemonic phrase: ")
|
mnemonicBuffer, err := secret.ReadMnemonic("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, fmt.Errorf("failed to read mnemonic: %w", err)
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||||
|
|
||||||
return mnemonicBuffer.String(), mnemonicBuffer.Destroy, nil
|
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||||
}
|
|
||||||
|
|
||||||
// setupDefaultVault creates the default vault and derives its long-term
|
|
||||||
// identity from the mnemonic
|
|
||||||
func (cli *Instance) setupDefaultVault(
|
|
||||||
stateDir, mnemonicStr string,
|
|
||||||
) (*vault.Vault, *age.X25519Identity, error) {
|
|
||||||
// Create the default vault - it will handle key derivation internally
|
|
||||||
secret.Debug("Creating default vault")
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default")
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to create default vault", "error", err)
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to create default vault: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", "default")
|
|
||||||
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to load vault metadata", "error", err)
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the long-term key using the same index that CreateVault used
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to derive long-term key", "error", err)
|
|
||||||
|
|
||||||
return nil, nil, fmt.Errorf(
|
|
||||||
"failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return vlt, ltIdentity, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init initializes the secret manager, holding the state directory lock
|
// Init initializes the secret manager, holding the state directory lock
|
||||||
@@ -136,12 +100,13 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prompt for mnemonic
|
// Prompt for mnemonic
|
||||||
mnemonicStr, cleanupMnemonic, err := promptMnemonic()
|
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer cleanupMnemonic()
|
defer cleanupMnemonic()
|
||||||
|
|
||||||
|
mnemonicStr := mnemonic.String()
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
secret.Debug("Empty mnemonic provided")
|
secret.Debug("Empty mnemonic provided")
|
||||||
|
|
||||||
@@ -160,45 +125,39 @@ func (cli *Instance) initialize(cmd *cobra.Command) error {
|
|||||||
errInvalidMnemonicPhrase)
|
errInvalidMnemonicPhrase)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mnemonic in environment for CreateVault to use
|
// Ask for the unlocker passphrase before creating the vault, so that
|
||||||
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
// stopping at the prompt leaves no vault without an unlocker behind
|
||||||
defer restoreMnemonicEnv()
|
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
||||||
|
|
||||||
// Create the default vault and derive its long-term key
|
|
||||||
vlt, ltIdentity, err := cli.setupDefaultVault(stateDir, mnemonicStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cleanupPassphrase()
|
||||||
|
|
||||||
ltPubKey := ltIdentity.Recipient().String()
|
// Create the default vault with its passphrase unlocker
|
||||||
|
secret.Debug("Creating default vault")
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, "default",
|
||||||
vlt.Unlock(ltIdentity)
|
mnemonic, passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create default vault", "error", err)
|
||||||
|
|
||||||
// Prompt for passphrase for unlocker
|
return fmt.Errorf("failed to create default vault: %w", err)
|
||||||
passphraseBuffer, err := resolvePassphrase()
|
}
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to create unlocker", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: CreatePassphraseUnlocker already encrypts and writes the long-term
|
|
||||||
// private key to longterm.age, so no need to do it again here.
|
|
||||||
|
|
||||||
if cmd != nil {
|
if cmd != nil {
|
||||||
cmd.Printf("\nDefault vault created and configured\n")
|
cmd.Printf("\nDefault vault created and configured\n")
|
||||||
cmd.Printf("Long-term public key: %s\n", ltPubKey)
|
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||||
cmd.Println("\nYour secret manager is ready to use!")
|
cmd.Println("\nYour secret manager is ready to use!")
|
||||||
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
cmd.Println("Note: When using SB_SECRET_MNEMONIC environment variable,")
|
||||||
cmd.Println("unlockers are not required for secret operations.")
|
cmd.Println("unlockers are not required for secret operations.")
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -16,7 +19,11 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/creack/pty"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -286,7 +293,7 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// Test 25: Concurrent operations
|
// Test 25: Concurrent operations
|
||||||
// Purpose: Test multiple simultaneous operations
|
// Purpose: Test multiple simultaneous operations
|
||||||
// Expected: Proper locking/synchronization, no corruption
|
// Expected: Proper locking/synchronization, no corruption
|
||||||
test25ConcurrentOperations(t, testMnemonic, runSecret, runSecretWithEnv)
|
test25ConcurrentOperations(t, tempDir, secretPath, testMnemonic, runSecret)
|
||||||
|
|
||||||
// Test 26: Large secret values
|
// Test 26: Large secret values
|
||||||
// Purpose: Test with large secret values (e.g., certificates)
|
// Purpose: Test with large secret values (e.g., certificates)
|
||||||
@@ -366,8 +373,15 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(defaultVaultDir, "unlockers.d")
|
||||||
verifyFileExists(t, unlockersDir)
|
verifyFileExists(t, unlockersDir)
|
||||||
|
|
||||||
|
// Check current-unlocker file names the unlocker's directory
|
||||||
|
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
||||||
|
verifyFileExists(t, currentUnlockerFile)
|
||||||
|
|
||||||
|
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
||||||
|
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
|
||||||
|
|
||||||
// Verify passphrase unlocker was created
|
// Verify passphrase unlocker was created
|
||||||
passphraseUnlockerDir := filepath.Join(unlockersDir, "passphrase")
|
passphraseUnlockerDir := filepath.Join(unlockersDir, string(currentUnlockerContent))
|
||||||
verifyFileExists(t, passphraseUnlockerDir)
|
verifyFileExists(t, passphraseUnlockerDir)
|
||||||
|
|
||||||
// Check unlocker metadata
|
// Check unlocker metadata
|
||||||
@@ -382,13 +396,6 @@ func test01Initialize(t *testing.T, tempDir, testMnemonic, testPassphrase string
|
|||||||
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
||||||
verifyFileExists(t, encryptedLTPubKey)
|
verifyFileExists(t, encryptedLTPubKey)
|
||||||
|
|
||||||
// Check current-unlocker file contains the relative path
|
|
||||||
currentUnlockerFile := filepath.Join(defaultVaultDir, "current-unlocker")
|
|
||||||
verifyFileExists(t, currentUnlockerFile)
|
|
||||||
|
|
||||||
currentUnlockerContent := readFile(t, currentUnlockerFile)
|
|
||||||
assert.Contains(t, string(currentUnlockerContent), "passphrase", "current unlocker should point to passphrase type")
|
|
||||||
|
|
||||||
// Verify vault-metadata.json in vault
|
// Verify vault-metadata.json in vault
|
||||||
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
vaultMetadata := filepath.Join(defaultVaultDir, "vault-metadata.json")
|
||||||
verifyFileExists(t, vaultMetadata)
|
verifyFileExists(t, vaultMetadata)
|
||||||
@@ -537,7 +544,8 @@ func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase st
|
|||||||
verifyFileExists(t, pubKeyFile)
|
verifyFileExists(t, pubKeyFile)
|
||||||
|
|
||||||
// Verify passphrase unlocker was created
|
// Verify passphrase unlocker was created
|
||||||
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", "passphrase")
|
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
|
||||||
|
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
|
||||||
verifyFileExists(t, passphraseUnlockerDir)
|
verifyFileExists(t, passphraseUnlockerDir)
|
||||||
|
|
||||||
// Check unlocker files
|
// Check unlocker files
|
||||||
@@ -672,10 +680,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
|
// Test that without mnemonic, we get an error: the passphrase unlocker
|
||||||
output, err = runSecret("get", "database/password")
|
// cannot ask for its passphrase, as the tests have no terminal
|
||||||
require.Error(t, err, "get should fail without unlock method")
|
_, err = runSecret("get", "database/password")
|
||||||
assert.Contains(t, output, "failed to unlock vault", "should indicate unlock failure")
|
require.ErrorIs(t, err, secret.ErrPassphraseNotRead, "get should fail without unlock method")
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
@@ -831,12 +839,11 @@ 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
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "--version", "", "database/password")
|
}, "get", "--version", "", "database/password")
|
||||||
|
|
||||||
require.Error(t, err, "get with an empty version should fail")
|
require.ErrorIs(t, err, vault.ErrVersionNotFound, "should reject the empty version")
|
||||||
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)) {
|
||||||
@@ -1150,11 +1157,7 @@ func testInvalidSecretNames(t *testing.T, testMnemonic string, runSecretWithStdi
|
|||||||
shouldFail := slices.Contains(definitelyInvalid, invalidName)
|
shouldFail := slices.Contains(definitelyInvalid, invalidName)
|
||||||
|
|
||||||
if shouldFail {
|
if shouldFail {
|
||||||
require.Error(t, err, "add '%s' should fail", invalidName)
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName, "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
|
||||||
@@ -1213,9 +1216,8 @@ 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
|
||||||
output, err = runSecret("move", "test/nonexistent", "test/destination")
|
_, err = runSecret("move", "test/nonexistent", "test/destination")
|
||||||
require.Error(t, err, "move non-existent should fail")
|
require.ErrorIs(t, err, vault.ErrSecretNotFound, "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{
|
||||||
@@ -1223,9 +1225,8 @@ 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")
|
||||||
|
|
||||||
output, err = runSecret("move", "test/renamed", "test/existing-dest")
|
_, err = runSecret("move", "test/renamed", "test/existing-dest")
|
||||||
require.Error(t, err, "move to existing destination should fail")
|
require.ErrorIs(t, err, vault.ErrSecretExists, "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{
|
||||||
@@ -1302,9 +1303,8 @@ 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
|
||||||
output, err = runSecretWithEnv(env, "move", "work:force/test", "default")
|
_, err = runSecretWithEnv(env, "move", "work:force/test", "default")
|
||||||
require.Error(t, err, "move without force should fail when dest exists")
|
require.ErrorIs(t, err, vault.ErrSecretExists, "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,9 +1419,8 @@ 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
|
||||||
output, err := runSecret("vault", "select", "nonexistent")
|
_, err = runSecret("vault", "select", "nonexistent")
|
||||||
require.Error(t, err, "selecting non-existent vault should fail")
|
require.ErrorIs(t, err, vault.ErrVaultNotFound, "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)) {
|
||||||
@@ -1442,11 +1441,10 @@ 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)
|
||||||
output, err := runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "default-only/secret")
|
}, "get", "default-only/secret")
|
||||||
require.Error(t, err, "should not be able to get default vault secret from work vault")
|
require.ErrorIs(t, err, vault.ErrSecretNotFound, "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{
|
||||||
@@ -1459,14 +1457,13 @@ 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)
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "work-only/secret")
|
}, "get", "work-only/secret")
|
||||||
require.Error(t, err, "should not be able to get work vault secret from default vault")
|
require.ErrorIs(t, err, vault.ErrSecretNotFound, "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")
|
||||||
@@ -1578,11 +1575,10 @@ 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
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "import", "imported/nonexistent", "--source", "/nonexistent/file")
|
}, "import", "imported/nonexistent", "--source", "/nonexistent/file")
|
||||||
require.Error(t, err, "importing non-existent file should fail")
|
require.ErrorIs(t, err, os.ErrNotExist, "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")
|
||||||
@@ -1897,11 +1893,10 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Get non-existent secret
|
// Get non-existent secret
|
||||||
output, err := runSecretWithEnv(map[string]string{
|
_, err := runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "nonexistent/secret")
|
}, "get", "nonexistent/secret")
|
||||||
require.Error(t, err, "get non-existent secret should fail")
|
require.ErrorIs(t, err, vault.ErrSecretNotFound, "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)
|
||||||
@@ -1931,32 +1926,28 @@ 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
|
||||||
output, err = runSecret("vault", "select", "nonexistent")
|
_, err = runSecret("vault", "select", "nonexistent")
|
||||||
require.Error(t, err, "select non-existent vault should fail")
|
require.ErrorIs(t, err, vault.ErrVaultNotFound, "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
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
secret.EnvUnlockPassphrase: testPassphrase,
|
secret.EnvUnlockPassphrase: testPassphrase,
|
||||||
}, "vault", "import", "nonexistent")
|
}, "vault", "import", "nonexistent")
|
||||||
require.Error(t, err, "import to non-existent vault should fail")
|
require.ErrorIs(t, err, vault.ErrVaultNotFound, "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
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "--version", "99999999.999", "database/password")
|
}, "get", "--version", "99999999.999", "database/password")
|
||||||
require.Error(t, err, "get non-existent version should fail")
|
require.ErrorIs(t, err, vault.ErrVersionNotFound, "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
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "version", "promote", "database/password", "99999999.999")
|
}, "version", "promote", "database/password", "99999999.999")
|
||||||
require.Error(t, err, "promote non-existent version should fail")
|
require.ErrorIs(t, err, vault.ErrVersionNotFound, "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) {
|
||||||
@@ -2009,28 +2000,35 @@ func test24EnvironmentVariables(t *testing.T, tempDir, secretPath, testMnemonic,
|
|||||||
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
|
assert.Equal(t, "env-test-value", strings.TrimSpace(string(cmdOutput2)))
|
||||||
}
|
}
|
||||||
|
|
||||||
func test25ConcurrentOperations(t *testing.T, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
func test25ConcurrentOperations(t *testing.T, tempDir, secretPath, testMnemonic string, runSecret func(...string) (string, error)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Make sure we're in default vault
|
// Make sure we're in default vault
|
||||||
_, err := runSecret("vault", "select", "default")
|
_, err := runSecret("vault", "select", "default")
|
||||||
require.NoError(t, err, "vault select should succeed")
|
require.NoError(t, err, "vault select should succeed")
|
||||||
|
|
||||||
// Run multiple concurrent reads
|
// Run multiple concurrent reads, as separate processes: within one
|
||||||
|
// process the first command to read the mnemonic would unset it for
|
||||||
|
// the others
|
||||||
const numReaders = 5
|
const numReaders = 5
|
||||||
|
|
||||||
errCh := make(chan error, numReaders)
|
errCh := make(chan error, numReaders)
|
||||||
|
|
||||||
for i := range numReaders {
|
for i := range numReaders {
|
||||||
go func(id int) {
|
go func(id int) {
|
||||||
output, err := runSecretWithEnv(map[string]string{
|
cmd := exec.CommandContext(t.Context(), secretPath, "get", "database/password")
|
||||||
secret.EnvMnemonic: testMnemonic,
|
cmd.Env = []string{
|
||||||
}, "get", "database/password")
|
secret.EnvStateDir + "=" + tempDir,
|
||||||
|
secret.EnvMnemonic + "=" + testMnemonic,
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
}
|
||||||
|
output, err := cmd.Output()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case err != nil:
|
case err != nil:
|
||||||
errCh <- fmt.Errorf("reader %d failed: %w", id, err)
|
errCh <- fmt.Errorf("reader %d failed: %w", id, err)
|
||||||
case strings.TrimSpace(output) == "":
|
case strings.TrimSpace(string(output)) == "":
|
||||||
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
|
errCh <- fmt.Errorf("%w: reader %d", errEmptyValue, id)
|
||||||
default:
|
default:
|
||||||
errCh <- nil
|
errCh <- nil
|
||||||
@@ -2352,11 +2350,10 @@ 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
|
||||||
output, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
}, "get", "post-backup/secret")
|
}, "get", "post-backup/secret")
|
||||||
require.Error(t, err, "post-backup secret should not exist after restore")
|
require.ErrorIs(t, err, vault.ErrSecretNotFound, "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")
|
||||||
}
|
}
|
||||||
@@ -2421,8 +2418,7 @@ 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.Error(t, err, "get should fail due to wrong derivation index")
|
require.ErrorIs(t, err, vault.ErrMnemonicMismatch, "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")
|
||||||
@@ -2535,3 +2531,145 @@ func copyFile(src, dst string) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// secretRmCommand makes a state directory whose vault "default" holds the
|
||||||
|
// secret "x", and returns `secret rm x` on the built binary against it, and
|
||||||
|
// the directory of "x". The vault has no unlocker, so making it derives no
|
||||||
|
// key from a passphrase.
|
||||||
|
func secretRmCommand(ctx context.Context, t *testing.T) (*exec.Cmd, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default", mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
require.NoError(t, vlt.AddSecret("x", value, false))
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "rm", "x")
|
||||||
|
cmd.Env = []string{
|
||||||
|
secret.EnvStateDir + "=" + stateDir,
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd, filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "x")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveWithoutTerminalFailsAtOnce runs `secret rm` without --force,
|
||||||
|
// with a stdin that is not a terminal and never delivers anything, as in a
|
||||||
|
// script or a CI job. It must fail at once, telling the user to pass
|
||||||
|
// --force, instead of waiting for an answer, and remove nothing.
|
||||||
|
func TestRemoveWithoutTerminalFailsAtOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Nobody writes to or closes the pipe, so reading it would block for good.
|
||||||
|
stdin, stdinWriter, err := os.Pipe()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
_ = stdinWriter.Close()
|
||||||
|
_ = stdin.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd, secretDir := secretRmCommand(ctx, t)
|
||||||
|
cmd.Stdin = stdin
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
|
||||||
|
require.NoError(t, ctx.Err(), "secret rm waited for an answer")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, string(output), "pass --force")
|
||||||
|
assert.DirExists(t, secretDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The next two tests run `secret rm` with a terminal on stdin or on stdout
|
||||||
|
// and stderr, not both: whether it asks must depend on stdin alone, where
|
||||||
|
// the answer is read from. pty.Open returns the two ends of a new terminal:
|
||||||
|
// tty is the end a program uses as its terminal, and ptmx the end the test
|
||||||
|
// reads what the terminal shows from and types into.
|
||||||
|
|
||||||
|
// TestRemoveIgnoresTerminalOnStdout runs `echo y | secret rm x` at a
|
||||||
|
// terminal. stdin is a pipe, so nobody can answer there, and the command
|
||||||
|
// must fail as in a script, removing nothing.
|
||||||
|
func TestRemoveIgnoresTerminalOnStdout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd, secretDir := secretRmCommand(ctx, t)
|
||||||
|
|
||||||
|
ptmx, tty, err := pty.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = ptmx.Close() }()
|
||||||
|
|
||||||
|
cmd.Stdin = strings.NewReader("y\n")
|
||||||
|
cmd.Stdout = tty
|
||||||
|
cmd.Stderr = tty
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
_ = tty.Close()
|
||||||
|
|
||||||
|
// The read ends once secret rm has exited and so closed the terminal.
|
||||||
|
shown, _ := io.ReadAll(ptmx)
|
||||||
|
|
||||||
|
require.Error(t, cmd.Wait())
|
||||||
|
assert.Contains(t, string(shown), "pass --force")
|
||||||
|
assert.DirExists(t, secretDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveAsksAtTerminalOnStdin runs `secret rm x | cat` at a terminal.
|
||||||
|
// It must ask on the terminal, and remove the secret when y is typed there.
|
||||||
|
func TestRemoveAsksAtTerminalOnStdin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd, secretDir := secretRmCommand(ctx, t)
|
||||||
|
|
||||||
|
ptmx, tty, err := pty.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer func() { _ = ptmx.Close() }()
|
||||||
|
|
||||||
|
cmd.Stdin = tty
|
||||||
|
// Not a file, so exec.Cmd connects stdout through a pipe.
|
||||||
|
cmd.Stdout = io.Discard
|
||||||
|
cmd.Stderr = tty
|
||||||
|
|
||||||
|
require.NoError(t, cmd.Start())
|
||||||
|
|
||||||
|
_ = tty.Close()
|
||||||
|
|
||||||
|
var (
|
||||||
|
shown []byte
|
||||||
|
char byte
|
||||||
|
)
|
||||||
|
|
||||||
|
terminal := bufio.NewReader(ptmx)
|
||||||
|
for !bytes.HasSuffix(shown, []byte("[y/N] ")) {
|
||||||
|
char, err = terminal.ReadByte()
|
||||||
|
require.NoError(t, err, "secret rm ended without asking: %s", shown)
|
||||||
|
|
||||||
|
shown = append(shown, char)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = ptmx.WriteString("y\n")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, cmd.Wait())
|
||||||
|
assert.NoDirExists(t, secretDir)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLeftoversRemovedByNextChangingCommand is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/75. It plants what a command
|
||||||
|
// killed part-way leaves in each directory where secret.TempDirFor and
|
||||||
|
// secret.WriteFileAtomic make temporary entries: a temporary directory
|
||||||
|
// holding a vault, secret, unlocker or version being added or removed, and
|
||||||
|
// a temporary file beside a file being replaced. `secret list` must leave
|
||||||
|
// them all, and the next command that takes the state directory lock, here
|
||||||
|
// `secret vault select` of the vault already current, must delete exactly
|
||||||
|
// them: a vault named like a temporary directory stays. The copy has no
|
||||||
|
// lock file yet, so that command, as after a killed one, finds no mark that
|
||||||
|
// the last holder of the lock finished.
|
||||||
|
func TestLeftoversRemovedByNextChangingCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, ".tmp-0", nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, vault.SelectVault(fs, testStateDir, "default"))
|
||||||
|
|
||||||
|
before := snapshotStateDir(t, fs)
|
||||||
|
|
||||||
|
vaultDir := testStateDir + "/vaults.d/default"
|
||||||
|
secretDir := vaultDir + "/secrets.d/x"
|
||||||
|
|
||||||
|
versions, err := secret.ListVersions(fs, secretDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, versions, 1)
|
||||||
|
|
||||||
|
for _, dir := range []string{
|
||||||
|
testStateDir + "/.tmp-1/default",
|
||||||
|
vaultDir + "/.tmp-2/x",
|
||||||
|
secretDir + "/.tmp-3/" + testVersion,
|
||||||
|
} {
|
||||||
|
require.NoError(t, fs.MkdirAll(dir, secret.DirPerms))
|
||||||
|
require.NoError(t, afero.WriteFile(fs, dir+"/value.age",
|
||||||
|
[]byte("encrypted"), secret.FilePerms))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range []string{
|
||||||
|
testStateDir + "/.currentvault.tmp-4",
|
||||||
|
vaultDir + "/.current-unlocker.tmp-5",
|
||||||
|
secretDir + "/.current.tmp-6",
|
||||||
|
secretDir + "/versions/" + versions[0] + "/.metadata.age.tmp-7",
|
||||||
|
} {
|
||||||
|
require.NoError(t, afero.WriteFile(fs, file,
|
||||||
|
[]byte("partial"), secret.FilePerms))
|
||||||
|
}
|
||||||
|
|
||||||
|
planted := snapshotStateDir(t, fs)
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
|
||||||
|
require.NoError(t, c.ListSecrets(cmd, false, false, ""))
|
||||||
|
require.Equal(t, planted, snapshotStateDir(t, fs))
|
||||||
|
|
||||||
|
require.NoError(t, c.SelectVault(cmd, "default"))
|
||||||
|
require.Equal(t, before, snapshotStateDir(t, fs))
|
||||||
|
}
|
||||||
+87
-48
@@ -2,9 +2,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -25,11 +27,6 @@ const (
|
|||||||
// once the lock is free.
|
// once the lock is free.
|
||||||
lockWait = 10 * time.Second
|
lockWait = 10 * time.Second
|
||||||
|
|
||||||
// heldWait is how long a test watches a command that must wait for the
|
|
||||||
// lock. A command that takes no lock changes the state directory well
|
|
||||||
// within it.
|
|
||||||
heldWait = 100 * time.Millisecond
|
|
||||||
|
|
||||||
// testPassphrase protects the passphrase unlockers the tests create.
|
// testPassphrase protects the passphrase unlockers the tests create.
|
||||||
testPassphrase = "test-passphrase"
|
testPassphrase = "test-passphrase"
|
||||||
|
|
||||||
@@ -55,15 +52,18 @@ func lockInBackground(t *testing.T, fs afero.Fs) <-chan func() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addAtOnce runs one add of the secret name per value, all at once, and
|
// addAtOnce runs one add of the secret name per value, all at once, and
|
||||||
// returns their errors.
|
// returns their errors. Each add is given mnemonic, which a forced add
|
||||||
|
// needs.
|
||||||
func addAtOnce(
|
func addAtOnce(
|
||||||
fs afero.Fs, stateDir, name string, force bool, values []string,
|
fs afero.Fs, stateDir, name string, force bool, values []string,
|
||||||
|
mnemonic *memguard.LockedBuffer,
|
||||||
) []error {
|
) []error {
|
||||||
errs := make(chan error, len(values))
|
errs := make(chan error, len(values))
|
||||||
|
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
go func() {
|
go func() {
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
cli.Mnemonic = mnemonic
|
||||||
cli.cmd = &cobra.Command{}
|
cli.cmd = &cobra.Command{}
|
||||||
cli.cmd.SetIn(strings.NewReader(value))
|
cli.cmd.SetIn(strings.NewReader(value))
|
||||||
|
|
||||||
@@ -95,9 +95,9 @@ func numbered(prefix string, count int) []string {
|
|||||||
// forced adds read the same highest version number and overwrite each
|
// forced adds read the same highest version number and overwrite each
|
||||||
// other's version. With it they behave as if run one after another.
|
// other's version. With it they behave as if run one after another.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
const adds = 8
|
const adds = 8
|
||||||
|
|
||||||
@@ -110,14 +110,14 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|||||||
{"real", afero.NewOsFs(), t.TempDir()},
|
{"real", afero.NewOsFs(), t.TempDir()},
|
||||||
} {
|
} {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default")
|
_, err := vault.CreateVault(tc.fs, tc.stateDir, "default", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// One add creates the secret; the others find that it exists
|
// One add creates the secret; the others find that it exists
|
||||||
created := 0
|
created := 0
|
||||||
|
|
||||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", false,
|
||||||
numbered("create", adds)) {
|
numbered("create", adds), mnemonic) {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
created++
|
created++
|
||||||
} else {
|
} else {
|
||||||
@@ -129,13 +129,15 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Every forced add stores a version of its own
|
// Every forced add stores a version of its own
|
||||||
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
for _, err := range addAtOnce(tc.fs, tc.stateDir, "shared", true,
|
||||||
numbered("force", adds)) {
|
numbered("force", adds), mnemonic) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
vlt, err := vault.GetCurrentVault(tc.fs, tc.stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = mnemonic
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -150,7 +152,8 @@ func TestConcurrentAddsKeepEveryVersion(t *testing.T) {
|
|||||||
value, err := vlt.GetSecretVersion("shared", version)
|
value, err := vlt.GetSecretVersion("shared", version)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
values[string(value)] = true
|
values[string(value.Bytes())] = true
|
||||||
|
value.Destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Len(t, values, adds+1, "every add stored its own value")
|
assert.Len(t, values, adds+1, "every add stored its own value")
|
||||||
@@ -178,11 +181,11 @@ func (r *readNotifier) Read(p []byte) (int, error) {
|
|||||||
// taken the state directory lock before reading, it would hold the lock
|
// taken the state directory lock before reading, it would hold the lock
|
||||||
// while waiting for encrypt's output, and encrypt would wait for the lock
|
// while waiting for encrypt's output, and encrypt would wait for the lock
|
||||||
// to store its key: neither would finish.
|
// to store its key: neither would finish.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptPipedIntoAdd(t *testing.T) {
|
func TestEncryptPipedIntoAdd(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default")
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("piped"), 0o600))
|
||||||
|
|
||||||
@@ -238,8 +241,10 @@ func TestFailedCommandReleasesLock(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
|
||||||
// Fails once it holds the lock: there is no current vault
|
// Fails once it holds the lock: there is no current vault. Without
|
||||||
err := cli.RemoveSecret(&cobra.Command{}, "missing", false)
|
// --force it would fail before taking the lock, on the check it makes
|
||||||
|
// before asking.
|
||||||
|
err := cli.RemoveSecret(&cobra.Command{}, "missing", true)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
|
|
||||||
select {
|
select {
|
||||||
@@ -274,24 +279,27 @@ func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// setupEveryCommand makes what each command in
|
// setupEveryCommand makes what each command in
|
||||||
// TestChangingCommandsWaitForLock needs: the current vault "default" with
|
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
||||||
// two versions of "test/secret", the vault "other" without a long-term key,
|
// versions of "test/secret", the vault "other" without a long-term key, for
|
||||||
// for vault import, and the file testInput. If withUnlocker is set, it also
|
// vault import, and the file testInput. There is no vault "default", which
|
||||||
// gives "default" a passphrase unlocker, which is slow. It returns the older
|
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
||||||
// version and the unlocker's ID.
|
// unlocker, which is slow. It returns the older version and the unlocker's
|
||||||
|
// ID.
|
||||||
func setupEveryCommand(
|
func setupEveryCommand(
|
||||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
t *testing.T, fs afero.Fs, withUnlocker bool,
|
||||||
) (string, string) {
|
) (string, string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
other, err := vault.CreateVault(fs, testStateDir, "other")
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
otherDir, err := other.GetDirectory()
|
otherDir, err := other.GetDirectory()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default")
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("older"), false)
|
addTestSecret(t, vlt, []byte("older"), false)
|
||||||
@@ -323,10 +331,28 @@ func setupEveryCommand(
|
|||||||
return versions[1], unlockerID
|
return versions[1], unlockerID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitingForLock reports whether a goroutine is stopped in
|
||||||
|
// vault.LockStateDir, waiting for the in-memory filesystem's lock. The
|
||||||
|
// stack trace of such a goroutine starts with the reason it waits,
|
||||||
|
// "[sync.Mutex.Lock]", and names LockStateDir.
|
||||||
|
func waitingForLock() bool {
|
||||||
|
stacks := make([]byte, 1<<20)
|
||||||
|
stacks = stacks[:runtime.Stack(stacks, true)]
|
||||||
|
|
||||||
|
for goroutine := range bytes.SplitSeq(stacks, []byte("\n\n")) {
|
||||||
|
if bytes.Contains(goroutine, []byte("[sync.Mutex.Lock")) &&
|
||||||
|
bytes.Contains(goroutine, []byte("vault.LockStateDir(")) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// requireWaitsForLock runs a command, given what setupEveryCommand made,
|
// requireWaitsForLock runs a command, given what setupEveryCommand made,
|
||||||
// while holding the state directory lock. The command must neither finish
|
// while holding the state directory lock. The command must neither finish
|
||||||
// nor change anything while the lock is held, and must succeed once it is
|
// nor change anything before it waits for the lock, and must succeed once
|
||||||
// released.
|
// the lock is released.
|
||||||
func requireWaitsForLock(
|
func requireWaitsForLock(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
withUnlocker bool,
|
withUnlocker bool,
|
||||||
@@ -336,7 +362,6 @@ func requireWaitsForLock(
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
olderVersion, unlockerID := setupEveryCommand(t, fs, withUnlocker)
|
||||||
before := stateDirModTimes(t, fs)
|
|
||||||
|
|
||||||
release, err := vault.LockStateDir(fs, testStateDir)
|
release, err := vault.LockStateDir(fs, testStateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -346,7 +371,15 @@ func requireWaitsForLock(
|
|||||||
release = sync.OnceFunc(release)
|
release = sync.OnceFunc(release)
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
|
// Taken only now, since taking the lock writes the lock file.
|
||||||
|
before := stateDirModTimes(t, fs)
|
||||||
|
|
||||||
|
unlockPassphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer unlockPassphrase.Destroy()
|
||||||
|
|
||||||
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
cli.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
cli.UnlockPassphrase = unlockPassphrase
|
||||||
cli.cmd = &cobra.Command{}
|
cli.cmd = &cobra.Command{}
|
||||||
cli.cmd.SetIn(strings.NewReader("value"))
|
cli.cmd.SetIn(strings.NewReader("value"))
|
||||||
cli.cmd.SetOut(io.Discard)
|
cli.cmd.SetOut(io.Discard)
|
||||||
@@ -355,14 +388,20 @@ func requireWaitsForLock(
|
|||||||
|
|
||||||
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
go func() { done <- run(cli, olderVersion, unlockerID) }()
|
||||||
|
|
||||||
select {
|
timeout := time.After(lockWait)
|
||||||
case err := <-done:
|
|
||||||
t.Fatalf("finished while the lock was held, with error %v", err)
|
for !waitingForLock() {
|
||||||
case <-time.After(heldWait):
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("finished while the lock was held, with error %v", err)
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatal("never waited for the lock")
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
assert.Equal(t, before, stateDirModTimes(t, fs),
|
assert.Equal(t, before, stateDirModTimes(t, fs),
|
||||||
"changed the state directory while the lock was held")
|
"changed the state directory before waiting for the lock")
|
||||||
|
|
||||||
release()
|
release()
|
||||||
|
|
||||||
@@ -377,11 +416,8 @@ func requireWaitsForLock(
|
|||||||
// TestChangingCommandsWaitForLock checks that each command that changes the
|
// TestChangingCommandsWaitForLock checks that each command that changes the
|
||||||
// state directory waits for its lock.
|
// state directory waits for its lock.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
//nolint:paralleltest // waitingForLock sees any test's command waiting for the lock
|
||||||
func TestChangingCommandsWaitForLock(t *testing.T) {
|
func TestChangingCommandsWaitForLock(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
name string
|
name string
|
||||||
withUnlocker bool
|
withUnlocker bool
|
||||||
@@ -399,8 +435,8 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
|||||||
{"encrypt", false, func(cli *Instance, _, _ string) error {
|
{"encrypt", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.Encrypt("key", testInput, "")
|
return cli.Encrypt("key", testInput, "")
|
||||||
}},
|
}},
|
||||||
{"rm", false, func(cli *Instance, _, _ string) error {
|
{"rm --force", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.RemoveSecret(cli.cmd, "test/secret", false)
|
return cli.RemoveSecret(cli.cmd, "test/secret", true)
|
||||||
}},
|
}},
|
||||||
{"move", false, func(cli *Instance, _, _ string) error {
|
{"move", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
|
return cli.MoveSecret(cli.cmd, "test/secret", "moved", false)
|
||||||
@@ -408,8 +444,8 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
|||||||
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
|
{"version promote", false, func(cli *Instance, olderVersion, _ string) error {
|
||||||
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
|
return cli.PromoteVersion(cli.cmd, "test/secret", olderVersion)
|
||||||
}},
|
}},
|
||||||
{"version rm", false, func(cli *Instance, olderVersion, _ string) error {
|
{"version rm --force", false, func(cli *Instance, olderVersion, _ string) error {
|
||||||
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion)
|
return cli.RemoveVersion(cli.cmd, "test/secret", olderVersion, true)
|
||||||
}},
|
}},
|
||||||
{"vault create", false, func(cli *Instance, _, _ string) error {
|
{"vault create", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.CreateVault(cli.cmd, "created")
|
return cli.CreateVault(cli.cmd, "created")
|
||||||
@@ -420,13 +456,13 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
|||||||
{"vault import", false, func(cli *Instance, _, _ string) error {
|
{"vault import", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.VaultImport(cli.cmd, "other")
|
return cli.VaultImport(cli.cmd, "other")
|
||||||
}},
|
}},
|
||||||
{"vault rm", false, func(cli *Instance, _, _ string) error {
|
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.RemoveVault(cli.cmd, "other", false)
|
return cli.RemoveVault(cli.cmd, "other", true)
|
||||||
}},
|
}},
|
||||||
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
{"unlocker add", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.UnlockersAdd("passphrase", cli.cmd)
|
return cli.UnlockersAdd("passphrase", cli.cmd)
|
||||||
}},
|
}},
|
||||||
{"unlocker rm", true, func(cli *Instance, _, unlockerID string) error {
|
{"unlocker rm --force", true, func(cli *Instance, _, unlockerID string) error {
|
||||||
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
|
return cli.UnlockersRemove(unlockerID, true, cli.cmd)
|
||||||
}},
|
}},
|
||||||
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
|
{"unlocker select", true, func(cli *Instance, _, unlockerID string) error {
|
||||||
@@ -445,15 +481,18 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
|||||||
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
// TestEncryptWithExistingKeyTakesNoLock checks that secret encrypt with a
|
||||||
// key that already exists, which only reads the state directory, finishes
|
// key that already exists, which only reads the state directory, finishes
|
||||||
// while another command holds the state directory lock.
|
// while another command holds the state directory lock.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default")
|
_, err := vault.CreateVault(fs, testStateDir, "default", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("input"), 0o600))
|
||||||
|
|
||||||
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
encrypt := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
encrypt.Mnemonic = mnemonic
|
||||||
encrypt.cmd = &cobra.Command{}
|
encrypt.cmd = &cobra.Command{}
|
||||||
encrypt.cmd.SetOut(io.Discard)
|
encrypt.cmd.SetOut(io.Discard)
|
||||||
|
|
||||||
@@ -482,11 +521,11 @@ func TestEncryptWithExistingKeyTakesNoLock(t *testing.T) {
|
|||||||
// state directory lock by the time it writes its output. Holding it while
|
// state directory lock by the time it writes its output. Holding it while
|
||||||
// streaming would stall every other changing command for as long as the
|
// streaming would stall every other changing command for as long as the
|
||||||
// stream lasts, and forever when the other end of the pipe is one of them.
|
// stream lasts, and forever when the other end of the pipe is one of them.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // times commands against the in-memory lock all tests share
|
||||||
func TestEncryptStreamsUnlocked(t *testing.T) {
|
func TestEncryptStreamsUnlocked(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "default")
|
_, err := vault.CreateVault(fs, testStateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
require.NoError(t, afero.WriteFile(fs, testInput, []byte("streamed"), 0o600))
|
||||||
|
|
||||||
|
|||||||
+190
-29
@@ -1,9 +1,14 @@
|
|||||||
package cli_test
|
package cli_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -14,9 +19,9 @@ import (
|
|||||||
// move within "work" left "work" the current vault. "default" is the current
|
// move within "work" left "work" the current vault. "default" is the current
|
||||||
// vault in every case, and each case runs on its own copy of the state
|
// vault in every case, and each case runs on its own copy of the state
|
||||||
// directory.
|
// directory.
|
||||||
//
|
|
||||||
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
||||||
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
||||||
|
|
||||||
@@ -25,6 +30,8 @@ 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
|
||||||
@@ -38,35 +45,12 @@ 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, so ".." cannot reach the state
|
|
||||||
// directory itself.
|
|
||||||
{
|
|
||||||
"mv --force ..:x ..:y", "..:x", "..:y", true,
|
|
||||||
"vault '..' does not exist",
|
|
||||||
},
|
|
||||||
// Each of these spells "work" a second way. The spelling is not an
|
|
||||||
// existing 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 'work/' does not exist",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work/:x work:", "work/:x", "work:", true,
|
|
||||||
"vault 'work/' does not exist",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"mv --force work:x ./work:x", workX, "./work:x", true,
|
|
||||||
"vault './work' does not exist",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := newFsFromSnapshot(t, before)
|
fs := newFsFromSnapshot(t, before)
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
|
||||||
@@ -76,14 +60,61 @@ 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
|
||||||
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
|
// work:y`, with "default" the current vault, renames "x" to "y" in "work" and
|
||||||
// leaves "default" the current vault.
|
// leaves "default" the current vault.
|
||||||
//
|
|
||||||
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
||||||
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
|
func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
fs := newTwoVaultFs(t)
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
|
||||||
@@ -97,3 +128,133 @@ func TestMoveWithinOtherVaultKeepsCurrentVault(t *testing.T) {
|
|||||||
require.Contains(t, after, workSecrets+"y/")
|
require.Contains(t, after, workSecrets+"y/")
|
||||||
require.NotContains(t, after, workSecrets+"x/")
|
require.NotContains(t, after, workSecrets+"x/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMoveOntoSameSecretUnderAnotherNameIsRejected is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/78: on a case-insensitive
|
||||||
|
// filesystem "Foo" and "foo" are one secret, and `secret mv --force Foo foo`
|
||||||
|
// removed the destination, which was the source. Symbolic links on the real
|
||||||
|
// filesystem give one secret two names here: in "default", "y" is a link to
|
||||||
|
// the secret "x", and the secrets.d of "other" is a link to that of
|
||||||
|
// "default", so other:x is default:x. Each move must be rejected and leave
|
||||||
|
// the secret and the links as they were.
|
||||||
|
func TestMoveOntoSameSecretUnderAnotherNameIsRejected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const isSame = "is the same secret on this filesystem"
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
command string
|
||||||
|
source, dest string
|
||||||
|
force bool
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"mv --force y x", "y", "x", true,
|
||||||
|
"secret 'y' cannot be moved onto itself: 'x' " + isSame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mv --force x y", "x", "y", true,
|
||||||
|
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mv x y", "x", "y", false,
|
||||||
|
"secret 'x' cannot be moved onto itself: 'y' " + isSame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mv --force default:x other:x", "default:x", "other:x", true,
|
||||||
|
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
|
||||||
|
isSame,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mv default:x other", "default:x", "other", false,
|
||||||
|
"secret 'default:x' cannot be moved onto itself: 'other:x' " +
|
||||||
|
isSame,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
vaultsDir := filepath.Join(stateDir, "vaults.d")
|
||||||
|
|
||||||
|
// "default" is created last, so it is the current vault.
|
||||||
|
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defaultSecrets := filepath.Join(vaultsDir, "default", "secrets.d")
|
||||||
|
otherSecrets := filepath.Join(vaultsDir, "other", "secrets.d")
|
||||||
|
link := filepath.Join(defaultSecrets, "y")
|
||||||
|
|
||||||
|
require.NoError(t, os.Symlink("x", link))
|
||||||
|
require.NoError(t, os.Remove(otherSecrets))
|
||||||
|
require.NoError(t, os.Symlink(defaultSecrets, otherSecrets))
|
||||||
|
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
moveErr := c.MoveSecret(&cobra.Command{}, tt.source, tt.dest, tt.force)
|
||||||
|
|
||||||
|
value, err := vlt.GetSecret("x")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
require.Equal(t, []byte("value"), value.Bytes())
|
||||||
|
|
||||||
|
target, err := os.Readlink(link)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "x", target)
|
||||||
|
|
||||||
|
target, err = os.Readlink(otherSecrets)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, defaultSecrets, target)
|
||||||
|
|
||||||
|
require.EqualError(t, moveErr, tt.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem checks that where "Foo"
|
||||||
|
// and "foo" are two secrets, `secret mv --force Foo foo` still replaces "foo"
|
||||||
|
// with "Foo".
|
||||||
|
func TestForcedCaseOnlyMoveOnCaseSensitiveFilesystem(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = vlt.AddSecret("Foo", memguard.NewBufferFromBytes([]byte("upper")), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = os.Stat(filepath.Join(stateDir, "vaults.d", "default", "secrets.d", "foo"))
|
||||||
|
if err == nil {
|
||||||
|
t.Skip("the temporary directory is on a case-insensitive filesystem")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = vlt.AddSecret("foo", memguard.NewBufferFromBytes([]byte("lower")), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
err = c.MoveSecret(&cobra.Command{}, "Foo", "foo", true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
value, err := vlt.GetSecret("foo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
require.Equal(t, []byte("upper"), value.Bytes())
|
||||||
|
|
||||||
|
_, err = vlt.GetSecret("Foo")
|
||||||
|
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/cli"
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
@@ -32,36 +33,65 @@ const (
|
|||||||
missingFile = "/no/such/file"
|
missingFile = "/no/such/file"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
||||||
|
// destroyed when the test ends.
|
||||||
|
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
t.Cleanup(mnemonic.Destroy)
|
||||||
|
|
||||||
|
return mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state directory newTwoVaultFs copies, recorded by snapshotStateDir.
|
||||||
|
// Creating a passphrase unlocker is slow by design, so the vaults are made
|
||||||
|
// once, by the first test that needs them.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // shared by the tests that use newTwoVaultFs
|
||||||
|
var (
|
||||||
|
twoVaultsOnce sync.Once
|
||||||
|
twoVaults map[string]string
|
||||||
|
)
|
||||||
|
|
||||||
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
// newTwoVaultFs returns an in-memory filesystem holding the vaults "work"
|
||||||
// and "default", the current one. Each holds the secret "x" and a
|
// and "default", the current one. Each holds the secret "x" and a
|
||||||
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
// passphrase unlocker, so both secrets.d and unlockers.d have contents.
|
||||||
|
// Every call returns a new copy of the same vaults.
|
||||||
//
|
//
|
||||||
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
//nolint:ireturn // afero.Fs is the filesystem abstraction used throughout
|
||||||
func newTwoVaultFs(t *testing.T) afero.Fs {
|
func newTwoVaultFs(t *testing.T) afero.Fs {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
twoVaultsOnce.Do(func() {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
for _, name := range []string{"work", "default"} {
|
||||||
|
vlt, err := vault.CreateVault(fs, testStateDir, name, mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
for _, name := range []string{"work", "default"} {
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, name)
|
require.NoError(t, err)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("value")), false)
|
_, err = vlt.CreatePassphraseUnlocker(
|
||||||
require.NoError(t, err)
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
_, err = vlt.CreatePassphraseUnlocker(
|
twoVaults = snapshotStateDir(t, fs)
|
||||||
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
})
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs
|
require.NotNil(t, twoVaults, "making the vaults failed in an earlier test")
|
||||||
|
|
||||||
|
return newFsFromSnapshot(t, twoVaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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()
|
||||||
|
|
||||||
@@ -74,6 +104,10 @@ func snapshotStateDir(t *testing.T, fs afero.Fs) map[string]string {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if path == testStateDir+"/lock" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
if info.IsDir() {
|
if info.IsDir() {
|
||||||
tree[path+"/"] = ""
|
tree[path+"/"] = ""
|
||||||
|
|
||||||
@@ -120,11 +154,10 @@ 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 an error with exactly the
|
// directory recorded in before. It requires the error want, so that a later
|
||||||
// message of want, so that a later check rejecting the argument does not
|
// check rejecting the argument does not count, and everything under the
|
||||||
// count, and everything under the state directory as it was: the error
|
// state directory as it was: the error alone proves nothing, since it could
|
||||||
// alone proves nothing, since it could come after the vault had already
|
// come after the vault had already been deleted.
|
||||||
// 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,
|
||||||
@@ -136,16 +169,16 @@ 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.EqualError(t, err, want.Error())
|
require.ErrorIs(t, err, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
// TestInvalidSecretNameLeavesVaultsUnchanged is a regression test for
|
||||||
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
|
// https://git.eeqj.de/sneak/secret/issues/33, where `secret rm ..` deleted
|
||||||
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
|
// the whole vault, and `secret rm .` or `secret rm ""` every secret in it.
|
||||||
// Moves and imports use --force, so that only the name check stands in
|
// Removals, moves and imports use --force, so that only the name check
|
||||||
// the way.
|
// stands in the way.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
//nolint:paralleltest // the cases share cmd
|
||||||
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
||||||
// Creating a passphrase unlocker is slow by design, so the vaults are
|
// Creating a passphrase unlocker is slow by design, so the vaults are
|
||||||
// created once and each case runs on its own copy of them.
|
// created once and each case runs on its own copy of them.
|
||||||
@@ -153,83 +186,82 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
|
|
||||||
vaultDir := testStateDir + "/vaults.d/default"
|
vaultDir := testStateDir + "/vaults.d/default"
|
||||||
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
require.Contains(t, before, vaultDir+"/secrets.d/x/")
|
||||||
require.Contains(t, before, vaultDir+"/unlockers.d/passphrase/")
|
require.Contains(t, before, vaultDir+"/current-unlocker")
|
||||||
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
require.Equal(t, "default", before[testStateDir+"/currentvault"])
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
command string
|
command string
|
||||||
rejected string // the secret name the command must reject
|
run func(c *cli.Instance) error
|
||||||
run func(c *cli.Instance) error
|
|
||||||
}{
|
}{
|
||||||
{"rm ..", "..", func(c *cli.Instance) error {
|
{"rm --force ..", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, "..", false)
|
return c.RemoveSecret(cmd, "..", true)
|
||||||
}},
|
}},
|
||||||
{"rm .", ".", func(c *cli.Instance) error {
|
{"rm --force .", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, ".", false)
|
return c.RemoveSecret(cmd, ".", true)
|
||||||
}},
|
}},
|
||||||
{`rm ""`, "", func(c *cli.Instance) error {
|
{`rm --force ""`, func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, "", false)
|
return c.RemoveSecret(cmd, "", true)
|
||||||
}},
|
}},
|
||||||
{"rm ../../etc", "../../etc", func(c *cli.Instance) error {
|
{"rm --force ../../etc", func(c *cli.Instance) error {
|
||||||
return c.RemoveSecret(cmd, "../../etc", false)
|
return c.RemoveSecret(cmd, "../../etc", true)
|
||||||
}},
|
}},
|
||||||
{"mv --force .. x", "..", func(c *cli.Instance) error {
|
{"mv --force .. x", func(c *cli.Instance) error {
|
||||||
return c.MoveSecret(cmd, "..", "x", true)
|
return c.MoveSecret(cmd, "..", "x", true)
|
||||||
}},
|
}},
|
||||||
{"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", "../../etc", func(c *cli.Instance) error {
|
{"import --force ../../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 ..", "..", func(c *cli.Instance) error {
|
{"version rm --force ..", func(c *cli.Instance) error {
|
||||||
return c.RemoveVersion(cmd, "..", testVersion)
|
return c.RemoveVersion(cmd, "..", testVersion, true)
|
||||||
}},
|
}},
|
||||||
{"encrypt ..", "..", func(c *cli.Instance) error {
|
{"encrypt ..", func(c *cli.Instance) error {
|
||||||
return c.Encrypt("..", "", "")
|
return c.Encrypt("..", "", "")
|
||||||
}},
|
}},
|
||||||
{"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.ValidateSecretName(tt.rejected), tt.run)
|
requireRejectedAndUnchanged(t, before, vault.ErrInvalidSecretName, tt.run)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -241,7 +273,7 @@ func TestInvalidSecretNameLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
// `secret version rm x ""` every version of x. A version argument is
|
// `secret version rm x ""` every version of x. A version argument is
|
||||||
// accepted only if it is one of the versions `secret version list` lists.
|
// accepted only if it is one of the versions `secret version list` lists.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
//nolint:paralleltest // the cases share cmd
|
||||||
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||||
|
|
||||||
@@ -251,8 +283,8 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
command string
|
command string
|
||||||
run func(c *cli.Instance, version string) error
|
run func(c *cli.Instance, version string) error
|
||||||
}{
|
}{
|
||||||
{"version rm x", func(c *cli.Instance, version string) error {
|
{"version rm --force x", func(c *cli.Instance, version string) error {
|
||||||
return c.RemoveVersion(cmd, "x", version)
|
return c.RemoveVersion(cmd, "x", version, true)
|
||||||
}},
|
}},
|
||||||
{"version promote x", func(c *cli.Instance, version string) error {
|
{"version promote x", func(c *cli.Instance, version string) error {
|
||||||
return c.PromoteVersion(cmd, "x", version)
|
return c.PromoteVersion(cmd, "x", version)
|
||||||
@@ -265,26 +297,85 @@ 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) {
|
||||||
want := fmt.Errorf("version '%s' %w '%s'",
|
requireRejectedAndUnchanged(t, before, vault.ErrVersionNotFound,
|
||||||
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) })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
|
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
||||||
// with a version that is not the current one removes that version and
|
// https://git.eeqj.de/sneak/secret/issues/68, where
|
||||||
// changes nothing else.
|
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
||||||
|
// state directory itself, and `secret vault select ..` made it the current
|
||||||
|
// vault. Each command that takes a vault name must reject an invalid one
|
||||||
|
// before building a path from it. The instance is given the mnemonic and
|
||||||
|
// the passphrase, and moves and removals use --force, so that only the name
|
||||||
|
// check stands in the way.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
//nolint:paralleltest // the cases share cmd
|
||||||
|
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
||||||
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||||
|
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
|
// Each command is a format with %q where the vault name goes.
|
||||||
|
commands := []struct {
|
||||||
|
command string
|
||||||
|
run func(c *cli.Instance, name string) error
|
||||||
|
}{
|
||||||
|
{"vault create %q", func(c *cli.Instance, name string) error {
|
||||||
|
return c.CreateVault(cmd, name)
|
||||||
|
}},
|
||||||
|
{"vault import %q", func(c *cli.Instance, name string) error {
|
||||||
|
return c.VaultImport(cmd, name)
|
||||||
|
}},
|
||||||
|
{"vault select %q", func(c *cli.Instance, name string) error {
|
||||||
|
return c.SelectVault(cmd, name)
|
||||||
|
}},
|
||||||
|
{"vault remove --force %q", func(c *cli.Instance, name string) error {
|
||||||
|
return c.RemoveVault(cmd, name, true)
|
||||||
|
}},
|
||||||
|
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
|
||||||
|
return c.MoveSecret(cmd, name+":x", "work:x", true)
|
||||||
|
}},
|
||||||
|
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
|
||||||
|
return c.MoveSecret(cmd, "default:x", name+":x", true)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range commands {
|
||||||
|
for _, name := range []string{"", ".", "..", "a/b"} {
|
||||||
|
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
||||||
|
requireRejectedAndUnchanged(t, before, vault.ErrInvalidVaultName,
|
||||||
|
func(c *cli.Instance) error {
|
||||||
|
c.Mnemonic = mnemonic
|
||||||
|
c.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
return tt.run(c, name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveVersionRemovesOnlyThatVersion checks that
|
||||||
|
// `secret version rm --force` with a version that is not the current one
|
||||||
|
// removes that version and changes nothing else.
|
||||||
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := newTwoVaultFs(t)
|
fs := newTwoVaultFs(t)
|
||||||
|
|
||||||
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
// A second version of "x" becomes the current one.
|
// A second version of "x" becomes the current one.
|
||||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("new")), true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -300,7 +391,7 @@ func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|||||||
require.Contains(t, before, oldDir)
|
require.Contains(t, before, oldDir)
|
||||||
|
|
||||||
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1])
|
err = c.RemoveVersion(&cobra.Command{}, "x", versions[1], true)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Expected: the state as before without everything under oldDir.
|
// Expected: the state as before without everything under oldDir.
|
||||||
@@ -318,9 +409,9 @@ func TestRemoveVersionRemovesOnlyThatVersion(t *testing.T) {
|
|||||||
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
// TestMoveToVaultNameRenamesInCurrentVault checks that `secret mv x work`,
|
||||||
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
// where "work" is also the name of a vault, renames the secret "x" to "work"
|
||||||
// in the current vault and changes nothing else.
|
// in the current vault and changes nothing else.
|
||||||
//
|
|
||||||
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
|
||||||
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
func TestMoveToVaultNameRenamesInCurrentVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
before := snapshotStateDir(t, newTwoVaultFs(t))
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||||
fs := newFsFromSnapshot(t, before)
|
fs := newFsFromSnapshot(t, before)
|
||||||
|
|
||||||
|
|||||||
+23
-2
@@ -46,9 +46,30 @@ func newRootCmd() *cobra.Command {
|
|||||||
Short: "A simple secrets manager",
|
Short: "A simple secrets manager",
|
||||||
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
Long: `A simple secrets manager to store and retrieve sensitive ` +
|
||||||
`information securely.`,
|
`information securely.`,
|
||||||
// Ensure usage is shown after errors
|
// Cobra prints the error a command returns; Entry does not.
|
||||||
SilenceUsage: false,
|
|
||||||
SilenceErrors: false,
|
SilenceErrors: false,
|
||||||
|
// Usage belongs only to a command called wrongly. Cobra has
|
||||||
|
// checked its arguments and flag values before this runs, but
|
||||||
|
// checks required flags (ValidateRequiredFlags) and flag groups
|
||||||
|
// (ValidateFlagGroups) only after it, so both are checked here
|
||||||
|
// to keep usage for them. An error after that comes from running
|
||||||
|
// the command, and usage would only bury it. A subcommand that
|
||||||
|
// sets its own PersistentPreRun replaces this one.
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
err := cmd.ValidateRequiredFlags()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.ValidateFlagGroups()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.SilenceUsage = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Adding subcommands to root command")
|
secret.Debug("Adding subcommands to root command")
|
||||||
|
|||||||
+175
-68
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -31,13 +32,7 @@ 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")
|
||||||
@@ -80,6 +75,9 @@ func newAddCmd() *cobra.Command {
|
|||||||
|
|
||||||
cli.cmd = cmd // Set the command for stdin access
|
cli.cmd = cmd // Set the command for stdin access
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
secret.Debug("Created CLI instance, calling AddSecret")
|
secret.Debug("Created CLI instance, calling AddSecret")
|
||||||
|
|
||||||
return cli.AddSecret(args[0], force)
|
return cli.AddSecret(args[0], force)
|
||||||
@@ -110,6 +108,9 @@ func newGetCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
// Without --version, get the current version. A given
|
// Without --version, get the current version. A given
|
||||||
// --version is checked as typed, so an empty one is rejected.
|
// --version is checked as typed, so an empty one is rejected.
|
||||||
if !cmd.Flags().Changed("version") {
|
if !cmd.Flags().Changed("version") {
|
||||||
@@ -173,6 +174,9 @@ func newImportCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
return cli.ImportSecret(cmd, args[0], sourceFile, force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -195,19 +199,25 @@ func newRemoveCmd() *cobra.Command {
|
|||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a secret from the vault",
|
Short: "Remove a secret from the vault",
|
||||||
Long: `Remove a secret and all its versions from the current ` +
|
Long: `Remove a secret and all its versions from the current ` +
|
||||||
`vault. This action is permanent and cannot be undone.`,
|
`vault. This action is permanent and cannot be undone. ` +
|
||||||
|
`Asks for confirmation first; when stdin is not a terminal, ` +
|
||||||
|
`fails unless --force is given.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.RemoveSecret(cmd, args[0], false)
|
return cli.RemoveSecret(cmd, args[0], force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +257,9 @@ The source secret is deleted after successful copy.`,
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.MoveSecret(cmd, args[0], args[1], force)
|
return cli.MoveSecret(cmd, args[0], args[1], force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -353,6 +366,8 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||||
|
|
||||||
// Read secret value directly into protected buffers
|
// Read secret value directly into protected buffers
|
||||||
@@ -413,22 +428,22 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
func (cli *Instance) GetSecret(cmd *cobra.Command, secretName string) error {
|
||||||
secret.Debug("GetSecret called", "secretName", secretName)
|
secret.Debug("GetSecret called", "secretName", secretName)
|
||||||
|
|
||||||
// Store the command for output
|
|
||||||
cli.cmd = cmd
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
value, err := vlt.GetSecret(secretName)
|
value, err := vlt.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
// Print the secret value to stdout
|
// Write the value straight from locked memory, with no trailing newline
|
||||||
_, _ = cli.Print(string(value))
|
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -441,9 +456,6 @@ func (cli *Instance) GetSecretWithVersion(
|
|||||||
secret.Debug("GetSecretWithVersion called",
|
secret.Debug("GetSecretWithVersion called",
|
||||||
"secretName", secretName, "version", version)
|
"secretName", secretName, "version", version)
|
||||||
|
|
||||||
// Store the command for output
|
|
||||||
cli.cmd = cmd
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -452,6 +464,8 @@ func (cli *Instance) GetSecretWithVersion(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Get the secret value
|
// Get the secret value
|
||||||
value, err := vlt.GetSecretVersion(secretName, version)
|
value, err := vlt.GetSecretVersion(secretName, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -459,22 +473,15 @@ func (cli *Instance) GetSecretWithVersion(
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
secret.Debug("Got secret value", "valueLength", len(value))
|
secret.Debug("Got secret value", "valueLength", value.Size())
|
||||||
|
|
||||||
// Print the secret value to stdout
|
// Write the value straight from locked memory, with no trailing newline
|
||||||
_, _ = cli.Print(string(value))
|
_, _ = cmd.OutOrStdout().Write(value.Bytes())
|
||||||
|
|
||||||
secret.Debug("Printed value to stdout")
|
secret.Debug("Printed value to stdout")
|
||||||
|
|
||||||
// Debug: Log what we're actually printing
|
|
||||||
secret.Debug("Secret retrieval debug info",
|
|
||||||
"secretName", secretName,
|
|
||||||
"version", version,
|
|
||||||
"valueLength", len(value),
|
|
||||||
"valueAsString", string(value),
|
|
||||||
"isEmpty", len(value) == 0)
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -644,6 +651,8 @@ func (cli *Instance) ImportSecret(
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Read secret value from the source file into protected buffers
|
// Read secret value from the source file into protected buffers
|
||||||
file, err := cli.fs.Open(sourceFile)
|
file, err := cli.fs.Open(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -658,10 +667,6 @@ 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)
|
||||||
@@ -690,29 +695,64 @@ func (cli *Instance) ImportSecret(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveSecret removes a secret from the vault
|
// RemoveSecret removes a secret and all its versions from the current
|
||||||
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
|
// vault, after asking the user to confirm unless force is set.
|
||||||
|
func (cli *Instance) RemoveSecret(
|
||||||
|
cmd *cobra.Command, secretName string, force bool,
|
||||||
|
) error {
|
||||||
err := vault.ValidateSecretName(secretName)
|
err := vault.ValidateSecretName(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
var found secretToRemove
|
||||||
|
|
||||||
|
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
found, err = cli.findSecretToRemove(secretName)
|
||||||
|
|
||||||
|
return found.question, err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Get current vault
|
err = secret.RemoveDirAtomic(cli.fs, found.dir)
|
||||||
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to remove secret: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
|
||||||
|
secretName, found.versions)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// secretToRemove is what removing a secret removes, as findSecretToRemove
|
||||||
|
// found it.
|
||||||
|
type secretToRemove struct {
|
||||||
|
// dir is the secret's directory, which holds all its versions.
|
||||||
|
dir string
|
||||||
|
versions int
|
||||||
|
// question names what is removed, for the user to confirm.
|
||||||
|
question string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSecretToRemove checks that the secret exists in the current vault
|
||||||
|
// and counts its versions.
|
||||||
|
func (cli *Instance) findSecretToRemove(
|
||||||
|
secretName string,
|
||||||
|
) (secretToRemove, error) {
|
||||||
|
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return secretToRemove{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if secret exists
|
|
||||||
vaultDir, err := currentVlt.GetDirectory()
|
vaultDir, err := currentVlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return secretToRemove{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
encodedName := strings.ReplaceAll(secretName, "/", "%")
|
||||||
@@ -720,32 +760,30 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
|
|||||||
|
|
||||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return secretToRemove{},
|
||||||
|
fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
return secretToRemove{},
|
||||||
|
fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count versions for information
|
// A secret without a versions directory has no versions, and can
|
||||||
versionsDir := filepath.Join(secretDir, "versions")
|
// still be removed.
|
||||||
versionCount := 0
|
versions, err := afero.ReadDir(cli.fs, filepath.Join(secretDir, "versions"))
|
||||||
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
entries, err := afero.ReadDir(cli.fs, versionsDir)
|
return secretToRemove{}, fmt.Errorf(
|
||||||
if err == nil {
|
"failed to count the versions of secret '%s': %w", secretName, err)
|
||||||
versionCount = len(entries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the secret directory
|
return secretToRemove{
|
||||||
err = secret.RemoveDirAtomic(cli.fs, secretDir)
|
dir: secretDir,
|
||||||
if err != nil {
|
versions: len(versions),
|
||||||
return fmt.Errorf("failed to remove secret: %w", err)
|
question: fmt.Sprintf("Permanently remove secret '%s' and its %d "+
|
||||||
}
|
"version(s) from vault '%s'?",
|
||||||
|
secretName, len(versions), currentVlt.GetName()),
|
||||||
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
|
}, nil
|
||||||
secretName, versionCount)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MoveSecret moves or renames a secret (within or across vaults), holding
|
// MoveSecret moves or renames a secret (within or across vaults), holding
|
||||||
@@ -822,9 +860,9 @@ func (cli *Instance) moveSecret(
|
|||||||
cmd, vlt, srcSecretName, destSecretName, force)
|
cmd, vlt, srcSecretName, destSecretName, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both vaults must be existing vaults by exact name, so that two
|
// Both vault names must be valid and name existing vaults exactly, so
|
||||||
// spellings of one vault, such as "work" and "work/", are never taken for
|
// that two spellings of one vault, such as "work" and "work/", are never
|
||||||
// two vaults. A named vault does not become the current vault.
|
// taken for two vaults. A named vault does not become the current vault.
|
||||||
srcVault, err := cli.existingVault(srcVaultName)
|
srcVault, err := cli.existingVault(srcVaultName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -844,16 +882,22 @@ func (cli *Instance) moveSecret(
|
|||||||
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
// existingVault returns the vault with the given name, or an error if there
|
// existingVault returns the vault with the given name, or an error if the
|
||||||
// is none. Unlike vault.SelectVault, it leaves the current vault as it is.
|
// name is not a valid vault name or there is no such vault. Unlike
|
||||||
|
// vault.SelectVault, it leaves the current vault as it is.
|
||||||
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
||||||
|
err := vault.ValidateVaultName(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list vaults: %w", err)
|
return nil, fmt.Errorf("failed to list vaults: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !slices.Contains(vaults, name) {
|
if !slices.Contains(vaults, name) {
|
||||||
return nil, fmt.Errorf("vault '%s' %w", name, errVaultDoesNotExist)
|
return nil, fmt.Errorf("vault '%s' %w", name, vault.ErrVaultNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
return vault.NewVault(cli.fs, cli.stateDir, name), nil
|
||||||
@@ -884,12 +928,24 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' %w", source, errSecretNotFound)
|
return fmt.Errorf("secret '%s' %w", source, vault.ErrSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
destEncoded := strings.ReplaceAll(dest, "/", "%")
|
||||||
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
destDir := filepath.Join(vaultDir, "secrets.d", destEncoded)
|
||||||
|
|
||||||
|
// Removing a destination that is the source under another name, such as
|
||||||
|
// "foo" for "Foo" on a case-insensitive filesystem, would delete it too.
|
||||||
|
same, err := cli.sameDirectory(sourceDir, destDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if same {
|
||||||
|
return fmt.Errorf("secret '%s' %w: '%s' is the same secret on "+
|
||||||
|
"this filesystem", source, errMoveOntoItself, dest)
|
||||||
|
}
|
||||||
|
|
||||||
exists, err = afero.DirExists(cli.fs, destDir)
|
exists, err = afero.DirExists(cli.fs, destDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
return fmt.Errorf("failed to check if destination secret exists: %w", err)
|
||||||
@@ -897,7 +953,8 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
if !force {
|
if !force {
|
||||||
return fmt.Errorf("secret '%s' %w", dest, errSecretExistsNoForce)
|
return fmt.Errorf("secret '%s' %w (use --force to overwrite)",
|
||||||
|
dest, vault.ErrSecretExists)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = secret.RemoveDirAtomic(cli.fs, destDir)
|
err = secret.RemoveDirAtomic(cli.fs, destDir)
|
||||||
@@ -916,6 +973,31 @@ func (cli *Instance) moveSecretWithinVault(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sameDirectory reports whether the existing directory dir and the path
|
||||||
|
// other are one directory under two names, as secrets.d/Foo and
|
||||||
|
// secrets.d/foo are on a case-insensitive filesystem, or a directory and a
|
||||||
|
// symbolic link to it. Removing other to make room for dir would then delete
|
||||||
|
// dir. It is false if other does not exist, and always false on the
|
||||||
|
// in-memory filesystem, which has no such aliasing and whose files
|
||||||
|
// os.SameFile does not compare.
|
||||||
|
func (cli *Instance) sameDirectory(dir, other string) (bool, error) {
|
||||||
|
dirInfo, err := cli.fs.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
otherInfo, err := cli.fs.Stat(other)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check %s: %w", other, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.SameFile(dirInfo, otherInfo), nil
|
||||||
|
}
|
||||||
|
|
||||||
// moveSecretCrossVault handles moving between two different vaults. Its
|
// moveSecretCrossVault handles moving between two different vaults. Its
|
||||||
// caller, MoveSecret, has already checked both secret names and that both
|
// caller, MoveSecret, has already checked both secret names and that both
|
||||||
// vaults exist.
|
// vaults exist.
|
||||||
@@ -937,9 +1019,34 @@ 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, errSecretNotFound, srcVault.Name)
|
srcSecretName, vault.ErrSecretNotFound, srcVault.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The source is removed after the copy, so a destination that is the
|
||||||
|
// source under another name would be lost with it.
|
||||||
|
destVaultDir, err := destVault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get destination vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destStorageName := strings.ReplaceAll(destSecretName, "/", "%")
|
||||||
|
destSecretDir := filepath.Join(destVaultDir, "secrets.d", destStorageName)
|
||||||
|
|
||||||
|
same, err := cli.sameDirectory(srcSecretDir, destSecretDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if same {
|
||||||
|
return fmt.Errorf("secret '%s:%s' %w: '%s:%s' is the same secret on "+
|
||||||
|
"this filesystem", srcVault.Name, srcSecretName, errMoveOntoItself,
|
||||||
|
destVault.Name, destSecretName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copying needs the long-term keys of both vaults
|
||||||
|
srcVault.Mnemonic, srcVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
destVault.Mnemonic, destVault.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
// Unlock destination vault (will fail if neither mnemonic nor unlocker available)
|
||||||
_, err = destVault.GetOrDeriveLongTermKey()
|
_, err = destVault.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -71,11 +70,9 @@ func newSizeTestVault(t *testing.T) (afero.Fs, *vault.Vault) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set test mnemonic
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
_, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Set current vault
|
// Set current vault
|
||||||
@@ -96,8 +93,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.
|
// verifies the outcome: wantErr, or the secret stored when wantErr is nil.
|
||||||
func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
func runAddSecretSizeCase(t *testing.T, size int, wantErr error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
skipIfLockedMemoryTooLow(t, size)
|
skipIfLockedMemoryTooLow(t, size)
|
||||||
|
|
||||||
@@ -131,9 +128,8 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
|||||||
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 {
|
if wantErr != nil {
|
||||||
require.Error(t, err)
|
require.ErrorIs(t, err, wantErr)
|
||||||
assert.Contains(t, err.Error(), errMsg)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -143,13 +139,16 @@ func runAddSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, testData, retrievedValue,
|
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||||
"Retrieved secret should match original (without newline)")
|
"Retrieved secret should match original (without newline)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// runImportSecretSizeCase imports a secret file of the given size and
|
// runImportSecretSizeCase imports a secret file of the given size and
|
||||||
// verifies the outcome.
|
// verifies the outcome: wantErr, or the secret stored when wantErr is nil.
|
||||||
func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string) {
|
func runImportSecretSizeCase(t *testing.T, size int, wantErr error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
skipIfLockedMemoryTooLow(t, size)
|
skipIfLockedMemoryTooLow(t, size)
|
||||||
|
|
||||||
@@ -181,9 +180,8 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
|
|||||||
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 {
|
if wantErr != nil {
|
||||||
require.Error(t, err)
|
require.ErrorIs(t, err, wantErr)
|
||||||
assert.Contains(t, err.Error(), errMsg)
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -193,132 +191,118 @@ func runImportSecretSizeCase(t *testing.T, size int, wantErr bool, errMsg string
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||||
|
"Retrieved secret should match original")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
// TestAddSecretVariousSizes tests adding secrets of various sizes through stdin
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
||||||
func TestAddSecretVariousSizes(t *testing.T) {
|
func TestAddSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size int
|
size int
|
||||||
shouldError bool
|
wantErr error
|
||||||
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,
|
||||||
shouldError: true,
|
wantErr: errSecretTooLarge,
|
||||||
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.shouldError, tt.errorMsg)
|
runAddSecretSizeCase(t, tt.size, tt.wantErr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
// TestImportSecretVariousSizes tests importing secrets of various sizes from files
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
||||||
func TestImportSecretVariousSizes(t *testing.T) {
|
func TestImportSecretVariousSizes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
size int
|
size int
|
||||||
shouldError bool
|
wantErr error
|
||||||
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,
|
||||||
shouldError: true,
|
wantErr: errSecretTooLarge,
|
||||||
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.shouldError, tt.errorMsg)
|
runImportSecretSizeCase(t, tt.size, tt.wantErr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // subtests use t.Setenv via newSizeTestVault
|
//nolint:paralleltest // together the subtests lock more than the memlock limit
|
||||||
func TestAddSecretBufferGrowth(t *testing.T) {
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
// Test various sizes that should trigger buffer growth
|
// Test various sizes that should trigger buffer growth
|
||||||
sizes := []int{
|
sizes := []int{
|
||||||
@@ -375,16 +359,19 @@ func TestAddSecretBufferGrowth(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret(secretName)
|
retrievedValue, err := vlt.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, testData, retrievedValue,
|
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||||
"Retrieved secret should match original exactly")
|
"Retrieved secret should match original exactly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
// TestAddSecretStreamingBehavior tests that we handle streaming input correctly
|
||||||
//
|
|
||||||
//nolint:paralleltest // uses t.Setenv via newSizeTestVault
|
|
||||||
func TestAddSecretStreamingBehavior(t *testing.T) {
|
func TestAddSecretStreamingBehavior(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs, vlt := newSizeTestVault(t)
|
fs, vlt := newSizeTestVault(t)
|
||||||
|
|
||||||
// Create a custom reader that simulates slow streaming input
|
// Create a custom reader that simulates slow streaming input
|
||||||
@@ -416,7 +403,11 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
|
|||||||
// Verify the secret was stored correctly
|
// Verify the secret was stored correctly
|
||||||
retrievedValue, err := vlt.GetSecret("streaming-test")
|
retrievedValue, err := vlt.GetSecret("streaming-test")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, testData, retrievedValue, "Retrieved secret should match original")
|
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, testData, retrievedValue.Bytes(),
|
||||||
|
"Retrieved secret should match original")
|
||||||
}
|
}
|
||||||
|
|
||||||
// slowReader simulates a reader that returns data in small chunks
|
// slowReader simulates a reader that returns data in small chunks
|
||||||
|
|||||||
@@ -0,0 +1,374 @@
|
|||||||
|
// Unlock Failure Tests
|
||||||
|
//
|
||||||
|
// When a vault cannot be opened through its current unlocker, because a
|
||||||
|
// file the unlocker needs is missing or the passphrase is wrong, the error
|
||||||
|
// keeps its cause and ends by saying that the mnemonic still opens that
|
||||||
|
// vault, but only for a vault that the mnemonic does open, and not when the
|
||||||
|
// passphrase could not be read at all. When a secret's current file is
|
||||||
|
// missing, the error says how to make a version current again. Each test
|
||||||
|
// that pins such advice also follows it.
|
||||||
|
|
||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// mnemonicAdvice ends the error when the current vault "default", which
|
||||||
|
// its mnemonic opens, cannot be opened through its current unlocker.
|
||||||
|
mnemonicAdvice = "; the vault 'default' still opens with its mnemonic: " +
|
||||||
|
"run 'secret unlocker add passphrase' with SB_SECRET_MNEMONIC set " +
|
||||||
|
"to the mnemonic to give it a new unlocker"
|
||||||
|
|
||||||
|
// versionAdvice ends the error when a secret's current file cannot be
|
||||||
|
// read.
|
||||||
|
versionAdvice = "; this file only names the current version: " +
|
||||||
|
"'secret version list' lists the secret's versions, and " +
|
||||||
|
"'secret version promote' makes one of them current"
|
||||||
|
|
||||||
|
// unlockTestVaultDir is the directory of the vault "default" of
|
||||||
|
// newTwoVaultFs, the current vault, whose secret "x" is "value".
|
||||||
|
unlockTestVaultDir = testStateDir + "/vaults.d/default"
|
||||||
|
)
|
||||||
|
|
||||||
|
// currentUnlockerDir returns the directory of the current unlocker of the
|
||||||
|
// vault in vaultDir on fs.
|
||||||
|
func currentUnlockerDir(t *testing.T, fs afero.Fs, vaultDir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
unlockerName, err := afero.ReadFile(fs,
|
||||||
|
filepath.Join(vaultDir, "current-unlocker"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return filepath.Join(vaultDir, "unlockers.d", string(unlockerName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newUnlockTestCLI returns the directory of the current unlocker of the
|
||||||
|
// vault "default" on fs, a copy of the vaults of newTwoVaultFs, and a CLI
|
||||||
|
// instance on fs that has the unlock passphrase, as from the environment,
|
||||||
|
// but not the mnemonic.
|
||||||
|
func newUnlockTestCLI(t *testing.T, fs afero.Fs) (string, *cli.Instance) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
c := cli.NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||||
|
|
||||||
|
return currentUnlockerDir(t, fs, unlockTestVaultDir), c
|
||||||
|
}
|
||||||
|
|
||||||
|
// discardCmd returns a command whose output is discarded.
|
||||||
|
func discardCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSecret returns what `secret get name` prints.
|
||||||
|
func getSecret(t *testing.T, c *cli.Instance, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
require.NoError(t, c.GetSecret(cmd, name))
|
||||||
|
|
||||||
|
return out.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockFailureNamesMnemonic checks the error of `secret get` when a
|
||||||
|
// file that opening the vault through its current unlocker needs is
|
||||||
|
// missing: it keeps the cause, which names the file, and ends with the
|
||||||
|
// advice that the mnemonic still opens the vault. The test then follows
|
||||||
|
// that advice: `secret unlocker add passphrase`, with the mnemonic, gives
|
||||||
|
// the vault a new unlocker, which opens it.
|
||||||
|
func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
file string // the file removed
|
||||||
|
inVaultDir bool // the file is the vault's, not the unlocker's
|
||||||
|
want string // the message before the cause
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
file: "current-unlocker",
|
||||||
|
inVaultDir: true,
|
||||||
|
want: "failed to unlock vault: failed to get long-term key: " +
|
||||||
|
"failed to get current unlocker: " +
|
||||||
|
"failed to read current unlocker: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "priv.age",
|
||||||
|
want: "failed to unlock vault: failed to get long-term key: " +
|
||||||
|
"failed to get unlocker identity: " +
|
||||||
|
"failed to read unlocker private key: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "longterm.age",
|
||||||
|
want: "failed to unlock vault: failed to get long-term key: " +
|
||||||
|
"failed to read encrypted long-term private key: ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.file, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||||
|
|
||||||
|
path := filepath.Join(unlockerDir, tt.file)
|
||||||
|
|
||||||
|
if tt.inVaultDir {
|
||||||
|
path = filepath.Join(unlockTestVaultDir, tt.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, fs.Remove(path))
|
||||||
|
|
||||||
|
err := c.GetSecret(discardCmd(), "x")
|
||||||
|
|
||||||
|
var cause *os.PathError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &cause)
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
assert.Equal(t, path, cause.Path)
|
||||||
|
|
||||||
|
require.EqualError(t, err, tt.want+cause.Error()+mnemonicAdvice)
|
||||||
|
|
||||||
|
c.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||||
|
|
||||||
|
c.Mnemonic = nil
|
||||||
|
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
||||||
|
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
||||||
|
// error and ends with the advice that the mnemonic still opens the vault.
|
||||||
|
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||||
|
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||||
|
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||||
|
|
||||||
|
err := c.GetSecret(discardCmd(), "x")
|
||||||
|
|
||||||
|
var noMatch *age.NoIdentityMatchError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &noMatch)
|
||||||
|
|
||||||
|
require.EqualError(t, err, "failed to unlock vault: "+
|
||||||
|
"failed to get long-term key: failed to get unlocker identity: "+
|
||||||
|
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
||||||
|
noMatch.Error()+mnemonicAdvice)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
||||||
|
// the vault "work", which is not the current vault, when "work" cannot be
|
||||||
|
// opened through its current unlocker: the advice names "work" and says to
|
||||||
|
// select it first, since `secret unlocker add` acts on the current vault.
|
||||||
|
// The test then follows that advice, and the move succeeds.
|
||||||
|
func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newTwoVaultFs(t)
|
||||||
|
_, c := newUnlockTestCLI(t, fs)
|
||||||
|
|
||||||
|
path := filepath.Join(
|
||||||
|
currentUnlockerDir(t, fs, testStateDir+"/vaults.d/work"), "priv.age")
|
||||||
|
require.NoError(t, fs.Remove(path))
|
||||||
|
|
||||||
|
err := c.MoveSecret(discardCmd(), "default:x", "work:y", false)
|
||||||
|
|
||||||
|
var cause *os.PathError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &cause)
|
||||||
|
assert.Equal(t, path, cause.Path)
|
||||||
|
|
||||||
|
require.EqualError(t, err, "failed to unlock destination vault 'work': "+
|
||||||
|
"failed to get unlocker identity: failed to read unlocker private key: "+
|
||||||
|
cause.Error()+"; the vault 'work' still opens with its mnemonic: "+
|
||||||
|
"run 'secret vault select work', then 'secret unlocker add passphrase' "+
|
||||||
|
"with SB_SECRET_MNEMONIC set to the mnemonic to give it a new unlocker")
|
||||||
|
|
||||||
|
require.NoError(t, c.SelectVault(discardCmd(), "work"))
|
||||||
|
|
||||||
|
c.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||||
|
|
||||||
|
c.Mnemonic = nil
|
||||||
|
require.NoError(t, c.MoveSecret(discardCmd(), "default:x", "work:y", false))
|
||||||
|
assert.Equal(t, "value", getSecret(t, c, "y"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
||||||
|
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
||||||
|
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
||||||
|
// and adding one would need a passphrase read the same way, so the error
|
||||||
|
// is the cause alone, without the advice to use the mnemonic.
|
||||||
|
func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
stateDir := t.TempDir()
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
vlt, err := vault.CreateVault(
|
||||||
|
afero.NewOsFs(), stateDir, "default", mnemonic, passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
value := memguard.NewBufferFromBytes([]byte("value"))
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
require.NoError(t, vlt.AddSecret("x", value, false))
|
||||||
|
|
||||||
|
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||||
|
cmd := exec.CommandContext(t.Context(), secretBinaryPath(t), "get", "x")
|
||||||
|
cmd.Env = []string{
|
||||||
|
secret.EnvStateDir + "=" + stateDir,
|
||||||
|
"PATH=" + os.Getenv("PATH"),
|
||||||
|
"HOME=" + os.Getenv("HOME"),
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "Error: failed to unlock vault: "+
|
||||||
|
"failed to get long-term key: failed to get unlocker identity: "+
|
||||||
|
"failed to read passphrase: 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())
|
||||||
|
}
|
||||||
+205
-221
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -16,7 +17,6 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -39,17 +39,10 @@ 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")
|
|
||||||
errUnlockerExists = errors.New("unlocker already exists")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UnlockerInfo represents unlocker information for display
|
// UnlockerInfo represents unlocker information for display
|
||||||
@@ -231,6 +224,9 @@ func newUnlockerAddCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
unlockerType := args[0]
|
unlockerType := args[0]
|
||||||
|
|
||||||
// Validate unlocker type
|
// Validate unlocker type
|
||||||
@@ -266,10 +262,11 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
Use: "remove <unlocker-id>",
|
Use: "remove <unlocker-id>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove an unlocker",
|
Short: "Remove an unlocker",
|
||||||
Long: `Remove an unlocker from the current vault. Cannot remove ` +
|
Long: `Remove an unlocker from the current vault. Asks for ` +
|
||||||
`the last unlocker if the vault has secrets unless --force is ` +
|
`confirmation first, saying whether it is the vault's last ` +
|
||||||
`used. Warning: Without unlockers and without your mnemonic, ` +
|
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
||||||
`vault data will be permanently inaccessible.`,
|
`is given. Warning: Without unlockers and without your ` +
|
||||||
|
`mnemonic, vault data will be permanently inaccessible.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -285,7 +282,7 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolP("force", "f", false,
|
cmd.Flags().BoolP("force", "f", false,
|
||||||
"Force removal of last unlocker even if vault has secrets")
|
"Remove without asking for confirmation, even the last unlocker")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -312,91 +309,8 @@ func newUnlockerSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// unlockerIDFromDir constructs an unlocker of the given metadata type
|
// UnlockersList lists unlockers in the current vault, each under its ID,
|
||||||
// rooted at unlockerDir and returns its ID. Returns "" for unknown types
|
// the name of its directory in unlockers.d
|
||||||
// and, when includeSecureEnclave is false, for secure enclave unlockers.
|
|
||||||
func unlockerIDFromDir(
|
|
||||||
fs afero.Fs, unlockerDir string, metadata secret.UnlockerMetadata,
|
|
||||||
includeSecureEnclave bool,
|
|
||||||
) string {
|
|
||||||
// Create the appropriate unlocker instance
|
|
||||||
var unlocker secret.Unlocker
|
|
||||||
|
|
||||||
switch metadata.Type {
|
|
||||||
case unlockerTypePassphrase:
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(fs, unlockerDir, metadata)
|
|
||||||
case unlockerTypeKeychain:
|
|
||||||
unlocker = secret.NewKeychainUnlocker(fs, unlockerDir, metadata)
|
|
||||||
case unlockerTypePGP:
|
|
||||||
unlocker = secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
|
||||||
case unlockerTypeSecureEnclave:
|
|
||||||
if includeSecureEnclave {
|
|
||||||
unlocker = secret.NewSecureEnclaveUnlocker(fs, unlockerDir, metadata)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if unlocker == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return unlocker.GetID()
|
|
||||||
}
|
|
||||||
|
|
||||||
// findUnlockerIDByMetadata scans unlockersDir for the directory whose
|
|
||||||
// stored metadata matches the given type and creation time and returns
|
|
||||||
// the matching unlocker's ID. It returns ("", nil) when the directory is
|
|
||||||
// readable but holds no match, and a non-nil error when the directory
|
|
||||||
// itself cannot be read. Callers must distinguish the two: an unreadable
|
|
||||||
// directory means the unlocker's real ID is unknowable, so the entry has
|
|
||||||
// to be skipped rather than reported under a synthesized ID.
|
|
||||||
//
|
|
||||||
// A metadata file that cannot be read or parsed is skipped without a
|
|
||||||
// warning: every caller gets metadata from vault.ListUnlockers first,
|
|
||||||
// which has already warned about that directory.
|
|
||||||
func findUnlockerIDByMetadata(
|
|
||||||
fs afero.Fs, unlockersDir string, metadata secret.UnlockerMetadata,
|
|
||||||
includeSecureEnclave bool,
|
|
||||||
) (string, error) {
|
|
||||||
files, err := afero.ReadDir(fs, unlockersDir)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if !file.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDir := filepath.Join(unlockersDir, file.Name())
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
// Check if this is the right unlocker by comparing metadata
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var diskMetadata secret.UnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &diskMetadata)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Match by type and creation time
|
|
||||||
if diskMetadata.Type == metadata.Type &&
|
|
||||||
diskMetadata.CreatedAt.Equal(metadata.CreatedAt) {
|
|
||||||
return unlockerIDFromDir(fs, unlockerDir, diskMetadata,
|
|
||||||
includeSecureEnclave), nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnlockersList lists unlockers in the current vault
|
|
||||||
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
@@ -412,58 +326,23 @@ func (cli *Instance) UnlockersList(jsonOutput bool) error {
|
|||||||
currentUnlockerID = currentUnlocker.GetID()
|
currentUnlockerID = currentUnlocker.GetID()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the metadata first
|
unlockerMetadata, err := vlt.ListUnlockers()
|
||||||
unlockerMetadataList, err := vlt.ListUnlockers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load actual unlocker objects to get the proper IDs
|
|
||||||
var unlockers []UnlockerInfo
|
var unlockers []UnlockerInfo
|
||||||
|
|
||||||
for _, metadata := range unlockerMetadataList {
|
for _, unlockerID := range slices.Sorted(maps.Keys(unlockerMetadata)) {
|
||||||
// Create unlocker instance to get the proper ID
|
metadata := unlockerMetadata[unlockerID]
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not get vault directory while listing unlockers",
|
|
||||||
"error", err)
|
|
||||||
|
|
||||||
continue
|
unlockers = append(unlockers, UnlockerInfo{
|
||||||
}
|
ID: unlockerID,
|
||||||
|
|
||||||
// Find the unlocker directory by type and created time
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
unlockerID, err := findUnlockerIDByMetadata(
|
|
||||||
cli.fs, unlockersDir, metadata, true,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Could not read unlockers directory, skipping unlocker",
|
|
||||||
"unlockers_dir", unlockersDir, "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the proper ID using the unlocker's ID() method
|
|
||||||
var properID string
|
|
||||||
if unlockerID != "" {
|
|
||||||
properID = unlockerID
|
|
||||||
} else {
|
|
||||||
// Generate ID as fallback
|
|
||||||
properID = fmt.Sprintf("%s-%s",
|
|
||||||
metadata.CreatedAt.Format("2006-01-02.15.04"), metadata.Type)
|
|
||||||
secret.Warn("Could not create unlocker instance, using fallback ID",
|
|
||||||
"fallback_id", properID, "type", metadata.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerInfo := UnlockerInfo{
|
|
||||||
ID: properID,
|
|
||||||
Type: metadata.Type,
|
Type: metadata.Type,
|
||||||
CreatedAt: metadata.CreatedAt,
|
CreatedAt: metadata.CreatedAt,
|
||||||
Flags: metadata.Flags,
|
Flags: metadata.Flags,
|
||||||
IsCurrent: properID == currentUnlockerID,
|
IsCurrent: unlockerID == currentUnlockerID,
|
||||||
}
|
})
|
||||||
unlockers = append(unlockers, unlockerInfo)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if jsonOutput {
|
if jsonOutput {
|
||||||
@@ -555,7 +434,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)",
|
||||||
errUnsupportedUnlockerType, unlockerType, supportedTypes)
|
errInvalidUnlockerType, unlockerType, supportedTypes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -581,19 +460,19 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
// For passphrase unlockers, we don't need the vault to be unlocked
|
// For passphrase unlockers, we don't need the vault to be unlocked
|
||||||
// The CreatePassphraseUnlocker method will handle getting the
|
// The CreatePassphraseUnlocker method will handle getting the
|
||||||
// long-term key
|
// long-term key
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Check if passphrase is set in environment variable
|
// The new unlocker gets the passphrase from the environment, which also
|
||||||
var passphraseBuffer *memguard.LockedBuffer
|
// unlocks the current passphrase unlocker, else the one entered here
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
passphraseBuffer := cli.UnlockPassphrase
|
||||||
passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
|
if passphraseBuffer == nil {
|
||||||
} else {
|
|
||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read passphrase: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -602,19 +481,16 @@ func (cli *Instance) addPassphraseUnlocker(cmd *cobra.Command) error {
|
|||||||
|
|
||||||
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Created passphrase unlocker: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
// Auto-select the newly created unlocker
|
// CreatePassphraseUnlocker has already made it the current unlocker
|
||||||
autoSelectUnlocker(cmd, vlt, passphraseUnlocker.GetID())
|
cmd.Printf("Automatically selected as current unlocker\n")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
keychainUnlocker, err := secret.CreateKeychainUnlocker(
|
||||||
return errKeychainMacOSOnly
|
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
||||||
}
|
|
||||||
|
|
||||||
keychainUnlocker, err := secret.CreateKeychainUnlocker(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
return fmt.Errorf("failed to create macOS Keychain unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -640,11 +516,8 @@ 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 {
|
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(
|
||||||
return errSecureEnclaveMacOSOnly
|
cli.fs, cli.stateDir, cli.Mnemonic, cli.UnlockPassphrase)
|
||||||
}
|
|
||||||
|
|
||||||
seUnlocker, err := secret.CreateSecureEnclaveUnlocker(cli.fs, cli.stateDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
return fmt.Errorf("failed to create Secure Enclave unlocker: %w", err)
|
||||||
}
|
}
|
||||||
@@ -686,21 +559,28 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
return fmt.Errorf("failed to get current vault: %w", err)
|
return fmt.Errorf("failed to get current vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve the GPG key ID to its fingerprint
|
// Resolve the GPG key ID to its fingerprint, once: the duplicate check
|
||||||
|
// and the new unlocker's metadata both use this result
|
||||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
return fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this GPG key is already added
|
// Check if this GPG key is already added
|
||||||
expectedID := "pgp-" + fingerprint
|
exists, err := cli.pgpUnlockerExists(vlt, fingerprint)
|
||||||
|
|
||||||
err = cli.checkUnlockerExists(vlt, expectedID)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"could not check whether GPG key %s is already an unlocker: %w",
|
||||||
|
gpgKeyID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
return fmt.Errorf("GPG key %s %w", gpgKeyID, errGPGKeyAlreadyUnlocker)
|
||||||
}
|
}
|
||||||
|
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir, gpgKeyID)
|
pgpUnlocker, err := secret.CreatePGPUnlocker(cli.fs, cli.stateDir,
|
||||||
|
gpgKeyID, fingerprint, cli.Mnemonic, cli.UnlockPassphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -714,54 +594,123 @@ func (cli *Instance) addPGPUnlocker(cmd *cobra.Command) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockersRemove removes an unlocker with safety checks
|
// UnlockersRemove removes an unlocker from the current vault, after asking
|
||||||
|
// the user to confirm unless force is set.
|
||||||
func (cli *Instance) UnlockersRemove(
|
func (cli *Instance) UnlockersRemove(
|
||||||
unlockerID string, force bool, cmd *cobra.Command,
|
unlockerID string, force bool, cmd *cobra.Command,
|
||||||
) error {
|
) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
var found unlockerToRemove
|
||||||
|
|
||||||
|
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
found, err = cli.findUnlockerToRemove(unlockerID)
|
||||||
|
|
||||||
|
return found.question, err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Get current vault
|
return cli.removeUnlocker(unlockerID, found, cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlockerToRemove is what removing an unlocker removes, as
|
||||||
|
// findUnlockerToRemove found it.
|
||||||
|
type unlockerToRemove struct {
|
||||||
|
vlt *vault.Vault
|
||||||
|
// last is set when the unlocker counts as the vault's last one, and
|
||||||
|
// secrets is then the number of secrets in the vault.
|
||||||
|
last bool
|
||||||
|
secrets int
|
||||||
|
// question names what is removed, for the user to confirm.
|
||||||
|
question string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findUnlockerToRemove checks that the current vault has the unlocker and
|
||||||
|
// finds whether it is the vault's last one.
|
||||||
|
func (cli *Instance) findUnlockerToRemove(
|
||||||
|
unlockerID string,
|
||||||
|
) (unlockerToRemove, error) {
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return unlockerToRemove{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get list of unlockers
|
exists, err := vlt.HasUnlocker(unlockerID)
|
||||||
|
if err != nil {
|
||||||
|
return unlockerToRemove{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return unlockerToRemove{}, fmt.Errorf("unlocker with ID %s %w",
|
||||||
|
unlockerID, vault.ErrUnlockerNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get list of unlockers. It leaves out a directory whose metadata file
|
||||||
|
// is missing or cannot be checked for, read or parsed.
|
||||||
unlockers, err := vlt.ListUnlockers()
|
unlockers, err := vlt.ListUnlockers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list unlockers: %w", err)
|
return unlockerToRemove{},
|
||||||
|
fmt.Errorf("failed to list unlockers: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return unlockerToRemove{},
|
||||||
|
fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
|
found := unlockerToRemove{
|
||||||
|
vlt: vlt,
|
||||||
|
question: fmt.Sprintf("Permanently remove unlocker '%s' from vault "+
|
||||||
|
"'%s'? It is not the vault's last unlocker.",
|
||||||
|
unlockerID, vlt.GetName()),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we're removing the last unlocker
|
|
||||||
if len(unlockers) == 1 {
|
if len(unlockers) == 1 {
|
||||||
// Check if vault has secrets
|
_, found.last = unlockers[unlockerID]
|
||||||
numSecrets, err := vlt.NumSecrets()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to count secrets: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if numSecrets > 0 && !force {
|
|
||||||
cmd.Println("ERROR: Cannot remove the last unlocker when the " +
|
|
||||||
"vault contains secrets.")
|
|
||||||
cmd.Println("WARNING: Without unlockers, you MUST have your " +
|
|
||||||
"mnemonic phrase to decrypt the vault.")
|
|
||||||
cmd.Println("If you want to proceed anyway, use --force")
|
|
||||||
|
|
||||||
return errLastUnlocker
|
|
||||||
}
|
|
||||||
|
|
||||||
if numSecrets > 0 && force {
|
|
||||||
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
|
|
||||||
"have your mnemonic phrase to access this vault again!")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the unlocker
|
// unlockerID may instead name a directory left out of the list. If its
|
||||||
err = vlt.RemoveUnlocker(unlockerID)
|
// metadata file is missing or corrupt it is not a working unlocker, so
|
||||||
|
// removing it never removes the last one. If the file cannot be checked
|
||||||
|
// for or read, the unlocker may be the only working one, so removing it
|
||||||
|
// counts as removing the last unlocker.
|
||||||
|
if metadataUnreadable(cli.fs, filepath.Join(unlockersDir, unlockerID)) {
|
||||||
|
found.last = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if found.last {
|
||||||
|
found.secrets, err = vlt.NumSecrets()
|
||||||
|
if err != nil {
|
||||||
|
return unlockerToRemove{},
|
||||||
|
fmt.Errorf("failed to count secrets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found.question = fmt.Sprintf("Permanently remove unlocker '%s', "+
|
||||||
|
"the last unlocker of vault '%s', which holds %d secret(s)? "+
|
||||||
|
"Without an unlocker the vault opens only with its mnemonic.",
|
||||||
|
unlockerID, vlt.GetName(), found.secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeUnlocker removes the unlocker that findUnlockerToRemove found. The
|
||||||
|
// caller holds the state directory lock.
|
||||||
|
func (cli *Instance) removeUnlocker(
|
||||||
|
unlockerID string, found unlockerToRemove, cmd *cobra.Command,
|
||||||
|
) error {
|
||||||
|
if found.last && found.secrets > 0 {
|
||||||
|
cmd.Println("WARNING: Removing the last unlocker. You MUST " +
|
||||||
|
"have your mnemonic phrase to access this vault again!")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := found.vlt.RemoveUnlocker(unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -771,6 +720,20 @@ func (cli *Instance) UnlockersRemove(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// metadataUnreadable reports whether checking for or reading the metadata
|
||||||
|
// file in the unlocker directory unlockerDir fails. A missing file is not
|
||||||
|
// a failure.
|
||||||
|
func metadataUnreadable(fs afero.Fs, unlockerDir string) bool {
|
||||||
|
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
exists, err := afero.Exists(fs, metadataPath)
|
||||||
|
if err == nil && exists {
|
||||||
|
_, err = afero.ReadFile(fs, metadataPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return err != nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnlockerSelect selects an unlocker as current
|
// UnlockerSelect selects an unlocker as current
|
||||||
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||||
@@ -788,44 +751,65 @@ func (cli *Instance) UnlockerSelect(unlockerID string) error {
|
|||||||
return vlt.SelectUnlocker(unlockerID)
|
return vlt.SelectUnlocker(unlockerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkUnlockerExists checks if an unlocker with the given ID exists
|
// pgpUnlockerExists reports whether the vault already has a PGP unlocker
|
||||||
func (cli *Instance) checkUnlockerExists(vlt *vault.Vault, unlockerID string) error {
|
// for the GPG key with the given fingerprint. It returns an error, and no
|
||||||
// Get the list of unlockers and check if any match the ID
|
// answer, when unlockers.d or an unlocker's metadata file cannot be read;
|
||||||
unlockers, err := vlt.ListUnlockers()
|
// the caller must then not create the unlocker. It reads unlockers.d itself
|
||||||
if err != nil {
|
// because vault.ListUnlockers skips an unlocker it cannot read, which suits
|
||||||
secret.Warn("Could not list unlockers during duplicate check", "error", err)
|
// `unlocker list` but not this check: the skipped unlocker may be the
|
||||||
|
// duplicate. A directory whose metadata file is missing or corrupt is not
|
||||||
return nil // If we can't list unlockers, assume it doesn't exist
|
// a working unlocker and is passed over.
|
||||||
}
|
func (cli *Instance) pgpUnlockerExists(
|
||||||
|
vlt *vault.Vault, fingerprint string,
|
||||||
// Get vault directory to construct unlocker instances
|
) (bool, error) {
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Warn("Could not get vault directory during duplicate check",
|
return false, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
"error", err)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each unlocker's ID
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
for _, metadata := range unlockers {
|
entries, err := afero.ReadDir(cli.fs, unlockersDir)
|
||||||
// Construct the unlocker matching this metadata to get its ID
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
id, err := findUnlockerIDByMetadata(cli.fs, unlockersDir, metadata, true)
|
return false, nil
|
||||||
if err != nil {
|
}
|
||||||
secret.Warn(
|
|
||||||
"Could not read unlockers directory during duplicate check, "+
|
|
||||||
"skipping unlocker",
|
|
||||||
"unlockers_dir", unlockersDir, "error", err)
|
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"failed to read unlockers directory %s: %w", unlockersDir, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if id != "" && id == unlockerID {
|
unlockerDir := filepath.Join(unlockersDir, entry.Name())
|
||||||
return errUnlockerExists
|
|
||||||
|
metadataBytes, err := afero.ReadFile(
|
||||||
|
cli.fs, filepath.Join(unlockerDir, "unlocker-metadata.json"))
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"failed to read metadata of unlocker %s: %w", unlockerDir, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata secret.PGPUnlockerMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Type == unlockerTypePGP && metadata.GPGKeyID == fingerprint {
|
||||||
|
return true, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
||||||
|
const unknownTestGPGUserID = "not-in-keyring@example.com"
|
||||||
|
|
||||||
|
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
|
||||||
|
const (
|
||||||
|
addTestSecretName = "api-key"
|
||||||
|
addTestSecretValue = "value"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
|
||||||
|
// with a passphrase unlocker, getting the vault's long-term key from the
|
||||||
|
// mnemonic or, with no mnemonic given, from the passphrase unlocker. It
|
||||||
|
// then reads a secret with neither the mnemonic nor the passphrase given, so
|
||||||
|
// through the new unlocker, which the add selects.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||||
|
func TestAddPGPUnlocker(t *testing.T) {
|
||||||
|
newTestGPGKey(t)
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// mnemonic is the mnemonic given while the unlocker is added, or nil.
|
||||||
|
mnemonic *memguard.LockedBuffer
|
||||||
|
}{
|
||||||
|
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
|
||||||
|
{"long-term key from the current unlocker", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = vlt.AddSecret(addTestSecretName,
|
||||||
|
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = vlt.CreatePassphraseUnlocker(
|
||||||
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
instance.Mnemonic = test.mnemonic
|
||||||
|
instance.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||||
|
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||||
|
|
||||||
|
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||||
|
|
||||||
|
current, err := reopened.GetCurrentUnlocker()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, unlockerTypePGP, current.GetType())
|
||||||
|
|
||||||
|
value, err := reopened.GetSecret(addTestSecretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, addTestSecretValue, value.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
||||||
|
// the keyring does not hold fails at looking up the key's fingerprint and
|
||||||
|
// leaves no new unlocker directory. The error must come from the lookup: a
|
||||||
|
// lookup moved after anything is written would also come after getting the
|
||||||
|
// vault's long-term key, which fails first here: this vault's unlockers hold
|
||||||
|
// no keys.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||||
|
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
||||||
|
newTestGPGKey(t)
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
instance, cmd := newTestInstance(base)
|
||||||
|
cmd.Flags().String("keyid", unknownTestGPGUserID, "")
|
||||||
|
|
||||||
|
err := instance.addPGPUnlocker(cmd)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, secret.ErrGPGKeyNotFound)
|
||||||
|
assertDirEntries(t, base,
|
||||||
|
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName),
|
||||||
|
listTestUnlockerDirOne)
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
// Corrupt Unlocker Tests
|
||||||
|
//
|
||||||
|
// `secret unlocker select` and `secret unlocker remove` find an unlocker
|
||||||
|
// by its ID. These tests give the first unlocker, which sorts before the
|
||||||
|
// one the commands act on, metadata that is not JSON, and check that the
|
||||||
|
// commands step past it, and that it can itself be removed by its
|
||||||
|
// directory name, which `secret unlocker list` names in its warning, as can
|
||||||
|
// one with no metadata file. A last test checks that an unlocker whose
|
||||||
|
// metadata file cannot be read counts as the last unlocker when it is
|
||||||
|
// removed by its directory name.
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newCorruptUnlockerVault returns the two-unlocker test vault with the
|
||||||
|
// metadata of the first unlocker replaced by text that is not JSON.
|
||||||
|
func newCorruptUnlockerVault(t *testing.T) *afero.MemMapFs {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
fs := newListTestVault(t, 2)
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(testVaultDir(listTestVaultName), listTestUnlockersDirName,
|
||||||
|
listTestUnlockerDirOne, listTestMetadataFileName),
|
||||||
|
[]byte("not json"), listTestFilePerm))
|
||||||
|
|
||||||
|
return fs
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockerSelectSkipsCorruptUnlocker asserts that the second unlocker
|
||||||
|
// can be selected, and that the corrupt one, having no type to be used as,
|
||||||
|
// cannot be selected by its directory name.
|
||||||
|
func TestUnlockerSelectSkipsCorruptUnlocker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newCorruptUnlockerVault(t)
|
||||||
|
instance, _ := newTestInstance(fs)
|
||||||
|
|
||||||
|
require.NoError(t, instance.UnlockerSelect(listTestUnlockerDirTwo))
|
||||||
|
|
||||||
|
current, err := afero.ReadFile(fs,
|
||||||
|
filepath.Join(testVaultDir(listTestVaultName), "current-unlocker"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, listTestUnlockerDirTwo, string(current))
|
||||||
|
|
||||||
|
err = instance.UnlockerSelect(listTestUnlockerDirOne)
|
||||||
|
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockerRemoveWithCorruptUnlocker asserts that the second unlocker
|
||||||
|
// counts as the vault's last one, since the corrupt unlocker cannot unlock
|
||||||
|
// the vault, and that the corrupt one, removed by its directory name, does
|
||||||
|
// not. Either is removed once the user confirms.
|
||||||
|
func TestUnlockerRemoveWithCorruptUnlocker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unlockerID string
|
||||||
|
wantLast bool
|
||||||
|
wantEntries []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "the other unlocker",
|
||||||
|
unlockerID: listTestUnlockerDirTwo,
|
||||||
|
wantLast: true,
|
||||||
|
wantEntries: []string{listTestUnlockerDirOne},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "the corrupt unlocker by its directory name",
|
||||||
|
unlockerID: listTestUnlockerDirOne,
|
||||||
|
wantEntries: []string{listTestUnlockerDirTwo},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newCorruptUnlockerVault(t)
|
||||||
|
writeTestSecret(t, fs, testVaultDir(listTestVaultName))
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
|
||||||
|
found, err := instance.findUnlockerToRemove(tt.unlockerID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.wantLast, found.last)
|
||||||
|
|
||||||
|
instance.terminal = strings.NewReader("y\n")
|
||||||
|
require.NoError(t, instance.UnlockersRemove(tt.unlockerID, false, cmd))
|
||||||
|
|
||||||
|
assertDirEntries(t, fs,
|
||||||
|
filepath.Join(testVaultDir(listTestVaultName),
|
||||||
|
listTestUnlockersDirName),
|
||||||
|
tt.wantEntries...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockerRemoveWithoutMetadata asserts that a partial unlocker
|
||||||
|
// directory, one with no metadata file, removed by its directory name from
|
||||||
|
// a vault with secrets, does not count as the vault's last unlocker, since
|
||||||
|
// it cannot unlock the vault, so the question says it is not. It is
|
||||||
|
// removed once the user confirms.
|
||||||
|
func TestUnlockerRemoveWithoutMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := newListTestVault(t, 2)
|
||||||
|
vaultDir := testVaultDir(listTestVaultName)
|
||||||
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||||
|
|
||||||
|
require.NoError(t, fs.Remove(filepath.Join(
|
||||||
|
unlockersDir, listTestUnlockerDirOne, listTestMetadataFileName)))
|
||||||
|
writeTestSecret(t, fs, vaultDir)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
|
||||||
|
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, found.last)
|
||||||
|
assert.Contains(t, found.question, "not the vault's last unlocker")
|
||||||
|
|
||||||
|
instance.terminal = strings.NewReader("y\n")
|
||||||
|
require.NoError(t, instance.UnlockersRemove(listTestUnlockerDirOne, false, cmd))
|
||||||
|
assertDirEntries(t, fs, unlockersDir, listTestUnlockerDirTwo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnlockerRemoveWithUnreadableMetadata asserts that the only unlocker
|
||||||
|
// of a vault with secrets, removed by its directory name when its metadata
|
||||||
|
// file cannot be checked for or read, counts as the vault's last unlocker,
|
||||||
|
// so the question warns that it is: listing leaves it out, but it may
|
||||||
|
// still be the vault's only working unlocker. It is then removed. The
|
||||||
|
// state directory lock refuses the failing filesystem, so the test calls
|
||||||
|
// findUnlockerToRemove and removeUnlocker, which UnlockersRemove runs to
|
||||||
|
// make its checks and, once it holds the lock, to remove the unlocker.
|
||||||
|
func TestUnlockerRemoveWithUnreadableMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
vaultDir := testVaultDir(listTestVaultName)
|
||||||
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||||
|
failingPath := filepath.Join(unlockersDir, listTestUnlockerDirOne,
|
||||||
|
listTestMetadataFileName)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wrap func(base afero.Fs) afero.Fs
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "checking for the file fails",
|
||||||
|
wrap: func(base afero.Fs) afero.Fs {
|
||||||
|
return &metadataStatFailFs{Fs: base, uncheckablePath: failingPath}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reading the file fails",
|
||||||
|
wrap: func(base afero.Fs) afero.Fs {
|
||||||
|
return &metadataReadFailFs{Fs: base, unreadablePath: failingPath}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
writeTestSecret(t, base, vaultDir)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(tt.wrap(base))
|
||||||
|
|
||||||
|
found, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, found.last)
|
||||||
|
assert.Contains(t, found.question, "the last unlocker")
|
||||||
|
|
||||||
|
require.NoError(t,
|
||||||
|
instance.removeUnlocker(listTestUnlockerDirOne, found, cmd))
|
||||||
|
assertDirEntries(t, base, unlockersDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSameMetadataUnlockersHaveTheirOwnIDs writes two passphrase unlockers
|
||||||
|
// side by side whose metadata is the same, creation time included, as
|
||||||
|
// copying an unlocker directory leaves them. It asserts that `unlocker
|
||||||
|
// list` and the shell completion of `unlocker select` and `unlocker remove`
|
||||||
|
// give each its own ID, and that each is selected and removed by its ID
|
||||||
|
// alone. Keychain and Secure Enclave unlockers, which only macOS can add,
|
||||||
|
// get their IDs the same way.
|
||||||
|
func TestSameMetadataUnlockersHaveTheirOwnIDs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vaultDir := testVaultDir(listTestVaultName)
|
||||||
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||||
|
dirNames := []string{
|
||||||
|
"passphrase-2026-10-04.12.30.00.000000000",
|
||||||
|
"passphrase-2026-10-04.12.30.00.000000000-copy",
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, err := json.Marshal(secret.UnlockerMetadata{
|
||||||
|
Type: unlockerTypePassphrase,
|
||||||
|
CreatedAt: time.Date(2026, time.October, 4, 12, 30, 0, 0, time.UTC),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
for _, dirName := range dirNames {
|
||||||
|
dir := filepath.Join(unlockersDir, dirName)
|
||||||
|
require.NoError(t, fs.MkdirAll(dir, listTestDirPerm))
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(dir, listTestMetadataFileName), metadata,
|
||||||
|
listTestFilePerm))
|
||||||
|
}
|
||||||
|
|
||||||
|
listed := listUnlockersJSON(t, fs)
|
||||||
|
require.Len(t, listed, len(dirNames))
|
||||||
|
|
||||||
|
completed, _ := getUnlockerIDsCompletionFunc(fs, listTestStateDir)(
|
||||||
|
nil, nil, "")
|
||||||
|
assert.Equal(t, dirNames, completed)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
|
||||||
|
for i, unlocker := range listed {
|
||||||
|
assert.Equal(t, dirNames[i], unlocker.ID)
|
||||||
|
|
||||||
|
require.NoError(t, instance.UnlockerSelect(unlocker.ID))
|
||||||
|
|
||||||
|
current, err := afero.ReadFile(fs,
|
||||||
|
filepath.Join(vaultDir, "current-unlocker"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, dirNames[i], string(current))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The second one first: an ID both shared would remove the first one
|
||||||
|
require.NoError(t, instance.UnlockersRemove(listed[1].ID, true, cmd))
|
||||||
|
assertDirEntries(t, fs, unlockersDir, dirNames[0])
|
||||||
|
|
||||||
|
require.NoError(t, instance.UnlockersRemove(listed[0].ID, true, cmd))
|
||||||
|
assertDirEntries(t, fs, unlockersDir)
|
||||||
|
}
|
||||||
@@ -1,25 +1,13 @@
|
|||||||
// Unlocker List Tests
|
// Unlocker List Tests
|
||||||
//
|
//
|
||||||
// Tests for `secret unlocker list` behavior when the unlockers.d directory,
|
// Tests for `secret unlocker list` behavior when an unlocker's metadata
|
||||||
// or an unlocker's metadata in it, cannot be read while the listing is
|
// cannot be read or used:
|
||||||
// being rendered:
|
|
||||||
//
|
//
|
||||||
// - TestUnlockersListSkipsUnreadableUnlockersDir: an unreadable
|
|
||||||
// unlockers.d yields no rows rather than rows bearing synthesized IDs.
|
|
||||||
// - TestUnlockersListSkipsOnlyUnreadableEntries: a readable entry is
|
|
||||||
// still listed, with its real ID and its current-unlocker marker,
|
|
||||||
// when a later entry's scan fails.
|
|
||||||
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
// - TestUnlockersListToleratesCorruptMetadata: one unlocker's corrupt
|
||||||
// metadata does not stop the others from being listed.
|
// metadata does not stop the others from being listed.
|
||||||
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
// - TestUnlockersListSkipsUnreadableMetadata: an unlocker whose metadata
|
||||||
// file cannot be checked for or read is left out, and the other is
|
// file cannot be checked for or read is left out, and the other is
|
||||||
// still listed.
|
// still listed.
|
||||||
//
|
|
||||||
// The listing resolves each unlocker's real ID by rescanning unlockers.d
|
|
||||||
// after the vault has already enumerated it. If that rescan fails the ID
|
|
||||||
// is unknowable, so the entry must be skipped: a synthesized ID matches
|
|
||||||
// no `unlocker remove` or `unlocker select` argument and would also
|
|
||||||
// suppress the current-unlocker marker.
|
|
||||||
|
|
||||||
//nolint:testpackage // white-box test of unexported internals
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
package cli
|
package cli
|
||||||
@@ -48,18 +36,16 @@ const (
|
|||||||
// listTestVaultName is the name of that synthetic vault.
|
// listTestVaultName is the name of that synthetic vault.
|
||||||
listTestVaultName = "default"
|
listTestVaultName = "default"
|
||||||
|
|
||||||
// listTestGPGKeyID is the GPG key ID recorded in the readable PGP
|
// listTestGPGKeyID is the GPG key ID recorded, with a letter appended,
|
||||||
// unlocker's metadata. The unlocker's real ID is derived from it, and
|
// in the PGP unlockers' metadata.
|
||||||
// differs from the timestamp-derived fallback ID.
|
|
||||||
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
listTestGPGKeyID = "DEADBEEFDEADBEEF"
|
||||||
|
|
||||||
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
// listTestUnlockerDirOne and listTestUnlockerDirTwo are the unlocker
|
||||||
// directory names under unlockers.d.
|
// directory names under unlockers.d, and so the unlockers' IDs.
|
||||||
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
listTestUnlockerDirOne = "host-pgp-2026-08-09"
|
||||||
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
listTestUnlockerDirTwo = "host-pgp-2026-08-10"
|
||||||
|
|
||||||
// listTestUnlockersDirName is the directory the listing rescans to
|
// listTestUnlockersDirName is the directory holding the unlockers.
|
||||||
// resolve unlocker IDs.
|
|
||||||
listTestUnlockersDirName = "unlockers.d"
|
listTestUnlockersDirName = "unlockers.d"
|
||||||
|
|
||||||
// listTestMetadataFileName is the per-unlocker metadata file name.
|
// listTestMetadataFileName is the per-unlocker metadata file name.
|
||||||
@@ -74,25 +60,16 @@ const (
|
|||||||
// a successful open of unlockers.d.
|
// a successful open of unlockers.d.
|
||||||
var errUnlockersDirUnreadable = errors.New("permission denied")
|
var errUnlockersDirUnreadable = errors.New("permission denied")
|
||||||
|
|
||||||
// unlockersDirFailFs makes unlockers.d unreadable once it has been opened
|
// unlockersDirFailFs fails every open of unlockers.d, as when the
|
||||||
// successfully openBudget times. This reproduces the directory becoming
|
// directory cannot be read.
|
||||||
// unreadable (permission change, partially restored backup, EIO) between
|
|
||||||
// the vault's own enumeration and the per-entry rescan that resolves
|
|
||||||
// unlocker IDs.
|
|
||||||
type unlockersDirFailFs struct {
|
type unlockersDirFailFs struct {
|
||||||
afero.Fs
|
afero.Fs
|
||||||
|
|
||||||
openBudget int
|
|
||||||
opens int
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
func (f *unlockersDirFailFs) Open(name string) (afero.File, error) {
|
||||||
if filepath.Base(name) == listTestUnlockersDirName {
|
if filepath.Base(name) == listTestUnlockersDirName {
|
||||||
f.opens++
|
return nil, errUnlockersDirUnreadable
|
||||||
if f.opens > f.openBudget {
|
|
||||||
return nil, errUnlockersDirUnreadable
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
//nolint:wrapcheck // test double must return the wrapped Fs error as-is
|
||||||
@@ -142,8 +119,8 @@ func (f *metadataStatFailFs) Stat(name string) (os.FileInfo, error) {
|
|||||||
return f.Fs.Stat(name)
|
return f.Fs.Stat(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// writePGPUnlocker writes a PGP unlocker directory with metadata that
|
// writePGPUnlocker writes a PGP unlocker directory named dirName, with
|
||||||
// yields the real ID "pgp-<keyID>".
|
// metadata recording the GPG key ID keyID.
|
||||||
func writePGPUnlocker(
|
func writePGPUnlocker(
|
||||||
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
t *testing.T, fs afero.Fs, unlockersDir, dirName string,
|
||||||
createdAt time.Time, keyID string,
|
createdAt time.Time, keyID string,
|
||||||
@@ -224,44 +201,6 @@ func listUnlockersJSON(t *testing.T, fs afero.Fs) []UnlockerInfo {
|
|||||||
return decoded.Unlockers
|
return decoded.Unlockers
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnlockersListSkipsUnreadableUnlockersDir asserts that an unlockers.d
|
|
||||||
// which becomes unreadable after the vault enumerated it produces no rows,
|
|
||||||
// rather than rows carrying fabricated fallback IDs.
|
|
||||||
func TestUnlockersListSkipsUnreadableUnlockersDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 1)
|
|
||||||
// Budget of one: the vault's own ListUnlockers scan succeeds, the
|
|
||||||
// per-entry rescan that resolves the ID fails.
|
|
||||||
fs := &unlockersDirFailFs{Fs: base, openBudget: 1}
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
|
|
||||||
assert.Empty(t, unlockers,
|
|
||||||
"an unreadable unlockers.d must yield no rows, not fabricated IDs")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListSkipsOnlyUnreadableEntries asserts that a readable
|
|
||||||
// entry survives with its real ID and current-unlocker marker when a later
|
|
||||||
// entry's rescan fails.
|
|
||||||
func TestUnlockersListSkipsOnlyUnreadableEntries(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
base := newListTestVault(t, 2)
|
|
||||||
// Budget of two: ListUnlockers plus the first entry's rescan succeed,
|
|
||||||
// the second entry's rescan fails.
|
|
||||||
fs := &unlockersDirFailFs{Fs: base, openBudget: 2}
|
|
||||||
|
|
||||||
unlockers := listUnlockersJSON(t, fs)
|
|
||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
|
||||||
"only the entry whose directory was readable may be listed")
|
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID,
|
|
||||||
"the surviving row must carry the real unlocker ID")
|
|
||||||
assert.True(t, unlockers[0].IsCurrent,
|
|
||||||
"the current-unlocker marker must survive the skip")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
// TestUnlockersListReadableEntriesAreListed is the control case: with a
|
||||||
// fully readable unlockers.d every entry is listed with its real ID.
|
// fully readable unlockers.d every entry is listed with its real ID.
|
||||||
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
||||||
@@ -272,20 +211,21 @@ func TestUnlockersListReadableEntriesAreListed(t *testing.T) {
|
|||||||
unlockers := listUnlockersJSON(t, base)
|
unlockers := listUnlockersJSON(t, base)
|
||||||
|
|
||||||
require.Len(t, unlockers, 2)
|
require.Len(t, unlockers, 2)
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"A", unlockers[0].ID)
|
assert.Equal(t, listTestUnlockerDirOne, unlockers[0].ID)
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[1].ID)
|
assert.Equal(t, listTestUnlockerDirTwo, unlockers[1].ID)
|
||||||
assert.True(t, unlockers[0].IsCurrent)
|
assert.True(t, unlockers[0].IsCurrent)
|
||||||
assert.False(t, unlockers[1].IsCurrent)
|
assert.False(t, unlockers[1].IsCurrent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
// TestUnlockersListToleratesCorruptMetadata asserts that one unlocker with
|
||||||
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
// corrupt metadata does not stop the listing. Metadata that is not JSON
|
||||||
// leaves that unlocker out; PGP metadata without a usable GPG key ID lists
|
// leaves that unlocker out; PGP metadata without a usable GPG key ID, and
|
||||||
// it as "pgp-unknown". The healthy unlocker is listed with its real ID.
|
// metadata of an unknown type, are still listed, under the directory name
|
||||||
|
// like any other. The healthy unlocker is listed with its real ID.
|
||||||
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
healthyID := "pgp-" + listTestGPGKeyID + "A"
|
healthyID := listTestUnlockerDirOne
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -300,12 +240,17 @@ func TestUnlockersListToleratesCorruptMetadata(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "GPG key ID of the wrong type",
|
name: "GPG key ID of the wrong type",
|
||||||
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
metadata: `{"type": "pgp", "gpgKeyId": 42}`,
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "GPG key ID missing",
|
name: "GPG key ID missing",
|
||||||
metadata: `{"type": "pgp"}`,
|
metadata: `{"type": "pgp"}`,
|
||||||
wantIDs: []string{healthyID, "pgp-unknown"},
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown type",
|
||||||
|
metadata: `{"type": "unknown"}`,
|
||||||
|
wantIDs: []string{healthyID, listTestUnlockerDirTwo},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -371,7 +316,7 @@ func TestUnlockersListSkipsUnreadableMetadata(t *testing.T) {
|
|||||||
|
|
||||||
require.Len(t, unlockers, 1,
|
require.Len(t, unlockers, 1,
|
||||||
"only the unlocker with usable metadata may be listed")
|
"only the unlocker with usable metadata may be listed")
|
||||||
assert.Equal(t, "pgp-"+listTestGPGKeyID+"B", unlockers[0].ID,
|
assert.Equal(t, listTestUnlockerDirTwo, unlockers[0].ID,
|
||||||
"the listed row must carry the real unlocker ID")
|
"the listed row must carry the real unlocker ID")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
// Unreadable Directory Tests
|
||||||
|
//
|
||||||
|
// The checks that guard adding a PGP unlocker (is this key already an
|
||||||
|
// unlocker?), removing the last unlocker and removing a vault (does the
|
||||||
|
// vault hold secrets?), removing a secret (how many versions does it
|
||||||
|
// have?), and importing a mnemonic (does the vault already have a
|
||||||
|
// long-term key?) each look at the vault on disk before acting.
|
||||||
|
// When that look fails they must refuse to act, not read the failure as
|
||||||
|
// "nothing there" and go ahead.
|
||||||
|
//
|
||||||
|
// The tests make the look fail with a wrapper around the in-memory
|
||||||
|
// filesystem, which the state directory lock refuses. So they call the
|
||||||
|
// function each command runs once it holds the lock, such as addPGPUnlocker
|
||||||
|
// for UnlockersAdd, or, for a removal, the function that makes its checks,
|
||||||
|
// such as findVaultToRemove for RemoveVault, which runs again under the
|
||||||
|
// lock before anything is removed, with --force or without.
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// unreadableTestGPGUserID is the user ID of the throwaway GPG key the
|
||||||
|
// PGP unlocker tests generate, and the --keyid they pass.
|
||||||
|
unreadableTestGPGUserID = "unlocker-test@example.com"
|
||||||
|
|
||||||
|
// unreadableTestSecretName is the secret stored in the vaults the
|
||||||
|
// removal tests remove from.
|
||||||
|
unreadableTestSecretName = "api-key"
|
||||||
|
|
||||||
|
// unreadableTestOtherVault is a second vault for the vault removal
|
||||||
|
// test, since the last vault can never be removed.
|
||||||
|
unreadableTestOtherVault = "work"
|
||||||
|
|
||||||
|
// unreadableTestSecretsDirName is the directory holding a vault's
|
||||||
|
// secrets, and unreadableTestCurrentFileName the per-secret file
|
||||||
|
// naming its current version.
|
||||||
|
unreadableTestSecretsDirName = "secrets.d"
|
||||||
|
unreadableTestCurrentFileName = "current"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errStatFailed is returned by statFailFs in place of a successful stat.
|
||||||
|
var errStatFailed = errors.New("input/output error")
|
||||||
|
|
||||||
|
// statFailFs fails every Stat of one path, as an I/O or permission error
|
||||||
|
// on that path would.
|
||||||
|
type statFailFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *statFailFs) Stat(name string) (os.FileInfo, error) {
|
||||||
|
if name == f.path {
|
||||||
|
return nil, errStatFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Fs.Stat(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errOpenFailed is returned by openFailFs in place of a successful open.
|
||||||
|
var errOpenFailed = errors.New("permission denied")
|
||||||
|
|
||||||
|
// openFailFs fails every Open of one path, as a directory without read
|
||||||
|
// permission does: checking that it exists succeeds, listing it fails.
|
||||||
|
type openFailFs struct {
|
||||||
|
afero.Fs
|
||||||
|
|
||||||
|
path string
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||||
|
func (f *openFailFs) Open(name string) (afero.File, error) {
|
||||||
|
if name == f.path {
|
||||||
|
return nil, errOpenFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.Fs.Open(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testVaultDir returns the directory of the named vault in the synthetic
|
||||||
|
// state directory built by newListTestVault.
|
||||||
|
func testVaultDir(vaultName string) string {
|
||||||
|
return filepath.Join(listTestStateDir, "vaults.d", vaultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestInstance returns a CLI instance on fs whose output is discarded.
|
||||||
|
func newTestInstance(fs afero.Fs) (*Instance, *cobra.Command) {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetOut(io.Discard)
|
||||||
|
cmd.SetErr(io.Discard)
|
||||||
|
|
||||||
|
return &Instance{fs: fs, stateDir: listTestStateDir, cmd: cmd}, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertDirEntries asserts that dir holds exactly the named entries.
|
||||||
|
func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
entries, err := afero.ReadDir(fs, dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := make([]string, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
names = append(names, entry.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, want, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
|
||||||
|
// without a passphrase there, with a subkey for encryption, and returns the
|
||||||
|
// key's fingerprint.
|
||||||
|
func newTestGPGKey(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Not t.TempDir(): on macOS its path is too long for the gpg-agent
|
||||||
|
// socket, which is created inside GNUPGHOME there.
|
||||||
|
gnupgHome, err := os.MkdirTemp("", "gpg") //nolint:usetesting // short path
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(gnupgHome) })
|
||||||
|
t.Setenv("GNUPGHOME", gnupgHome)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
// Stop the gpg-agent that key generation starts; cleanups run in
|
||||||
|
// reverse order, so this happens before its directory is removed.
|
||||||
|
// t.Context is already canceled when cleanup runs.
|
||||||
|
ctx := context.WithoutCancel(t.Context())
|
||||||
|
_ = exec.CommandContext(ctx, "gpgconf", "--kill", "gpg-agent").Run()
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := exec.CommandContext(t.Context(), "gpg", "--batch",
|
||||||
|
"--pinentry-mode", "loopback", "--passphrase", "",
|
||||||
|
"--quick-gen-key", unreadableTestGPGUserID, "ed25519", "sign", "never",
|
||||||
|
).CombinedOutput()
|
||||||
|
require.NoError(t, err, "generating the test GPG key: %s", output)
|
||||||
|
|
||||||
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
|
||||||
|
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
|
||||||
|
"--pinentry-mode", "loopback", "--passphrase", "",
|
||||||
|
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
|
||||||
|
).CombinedOutput()
|
||||||
|
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
|
||||||
|
output)
|
||||||
|
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
// addTestPGPUnlocker runs `secret unlocker add pgp` for the test key
|
||||||
|
// against fs.
|
||||||
|
func addTestPGPUnlocker(fs afero.Fs) error {
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||||
|
|
||||||
|
return instance.addPGPUnlocker(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAddPGPUnlockerDuplicateCheck asserts that adding a PGP unlocker for
|
||||||
|
// a key that already has one fails, and creates no unlocker directory,
|
||||||
|
// when unlockers.d or the existing unlocker's metadata file cannot be
|
||||||
|
// read; and, as the control case, that the existing unlocker is refused
|
||||||
|
// as a duplicate when everything can be read.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||||
|
func TestAddPGPUnlockerDuplicateCheck(t *testing.T) {
|
||||||
|
fingerprint := newTestGPGKey(t)
|
||||||
|
unlockersDir := filepath.Join(
|
||||||
|
testVaultDir(listTestVaultName), listTestUnlockersDirName)
|
||||||
|
duplicateDir := filepath.Join(unlockersDir, listTestUnlockerDirTwo)
|
||||||
|
|
||||||
|
// newVaultWithDuplicate returns a vault holding an unlocker for the
|
||||||
|
// test key, beside the one newListTestVault writes.
|
||||||
|
newVaultWithDuplicate := func(t *testing.T) afero.Fs {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
writePGPUnlocker(t, base, unlockersDir, listTestUnlockerDirTwo,
|
||||||
|
time.Date(2026, time.August, 10, 12, 30, 0, 0, time.UTC),
|
||||||
|
fingerprint)
|
||||||
|
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failFs func(base afero.Fs) afero.Fs
|
||||||
|
wantErr error
|
||||||
|
// wantPath is the path the error must name.
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unlockers.d unreadable",
|
||||||
|
failFs: func(base afero.Fs) afero.Fs {
|
||||||
|
return &unlockersDirFailFs{Fs: base}
|
||||||
|
},
|
||||||
|
wantErr: errUnlockersDirUnreadable,
|
||||||
|
wantPath: unlockersDir,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "existing unlocker's metadata unreadable",
|
||||||
|
failFs: func(base afero.Fs) afero.Fs {
|
||||||
|
return &metadataReadFailFs{
|
||||||
|
Fs: base,
|
||||||
|
unreadablePath: filepath.Join(
|
||||||
|
duplicateDir, listTestMetadataFileName),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
wantErr: errMetadataUnreadable,
|
||||||
|
wantPath: duplicateDir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
base := newVaultWithDuplicate(t)
|
||||||
|
|
||||||
|
err := addTestPGPUnlocker(tt.failFs(base))
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
require.NotErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantPath,
|
||||||
|
"the error must name what it could not read")
|
||||||
|
assertDirEntries(t, base, unlockersDir,
|
||||||
|
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("duplicate refused", func(t *testing.T) {
|
||||||
|
base := newVaultWithDuplicate(t)
|
||||||
|
|
||||||
|
err := addTestPGPUnlocker(base)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, errGPGKeyAlreadyUnlocker)
|
||||||
|
assertDirEntries(t, base, unlockersDir,
|
||||||
|
listTestUnlockerDirOne, listTestUnlockerDirTwo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTestSecret stores a secret with a current-version pointer, which is
|
||||||
|
// what makes it count as a secret, in the given vault directory.
|
||||||
|
func writeTestSecret(t *testing.T, fs afero.Fs, vaultDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
secretDir := filepath.Join(
|
||||||
|
vaultDir, unreadableTestSecretsDirName, unreadableTestSecretName)
|
||||||
|
require.NoError(t, fs.MkdirAll(secretDir, listTestDirPerm))
|
||||||
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(secretDir, unreadableTestCurrentFileName),
|
||||||
|
[]byte("20260809.001"), listTestFilePerm))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveLastUnlockerAbortsWhenSecretsUnreadable asserts that the last
|
||||||
|
// unlocker is kept when the secrets it protects cannot be counted.
|
||||||
|
func TestRemoveLastUnlockerAbortsWhenSecretsUnreadable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
vaultDir := testVaultDir(listTestVaultName)
|
||||||
|
unlockersDir := filepath.Join(vaultDir, listTestUnlockersDirName)
|
||||||
|
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
||||||
|
|
||||||
|
for _, path := range []string{
|
||||||
|
secretsDir,
|
||||||
|
filepath.Join(secretsDir, unreadableTestSecretName,
|
||||||
|
unreadableTestCurrentFileName),
|
||||||
|
} {
|
||||||
|
t.Run(filepath.Base(path), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
writeTestSecret(t, base, vaultDir)
|
||||||
|
instance, _ := newTestInstance(&statFailFs{Fs: base, path: path})
|
||||||
|
|
||||||
|
_, err := instance.findUnlockerToRemove(listTestUnlockerDirOne)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, errStatFailed)
|
||||||
|
assertDirEntries(t, base, unlockersDir, listTestUnlockerDirOne)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRemoveVaultAbortsWhenSecretsDirUnreadable asserts that a vault is
|
||||||
|
// kept when whether it holds secrets cannot be determined: when checking
|
||||||
|
// that secrets.d exists fails, and when it exists but cannot be listed.
|
||||||
|
func TestRemoveVaultAbortsWhenSecretsDirUnreadable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
vaultDir := testVaultDir(unreadableTestOtherVault)
|
||||||
|
secretsDir := filepath.Join(vaultDir, unreadableTestSecretsDirName)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
failFs func(base afero.Fs) afero.Fs
|
||||||
|
wantErr error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "check fails",
|
||||||
|
failFs: func(base afero.Fs) afero.Fs {
|
||||||
|
return &statFailFs{Fs: base, path: secretsDir}
|
||||||
|
},
|
||||||
|
wantErr: errStatFailed,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "listing fails",
|
||||||
|
failFs: func(base afero.Fs) afero.Fs {
|
||||||
|
return &openFailFs{Fs: base, path: secretsDir}
|
||||||
|
},
|
||||||
|
wantErr: errOpenFailed,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
writeTestSecret(t, base, vaultDir)
|
||||||
|
instance, _ := newTestInstance(tt.failFs(base))
|
||||||
|
|
||||||
|
_, err := instance.findVaultToRemove(unreadableTestOtherVault)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, tt.wantErr)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(base, vaultDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "the vault must not be removed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// stops when whether the vault already has a long-term key cannot be
|
||||||
|
// determined.
|
||||||
|
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
instance, cmd := newTestInstance(&statFailFs{
|
||||||
|
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
|
||||||
|
})
|
||||||
|
|
||||||
|
err := instance.importMnemonic(cmd, listTestVaultName)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, errStatFailed)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package cli_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/cli"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// usageHeading starts the usage text cobra prints after an error.
|
||||||
|
const usageHeading = "Usage:"
|
||||||
|
|
||||||
|
// A command called wrongly gets usage after its error; a command that
|
||||||
|
// fails while running gets its error alone. Either way the command fails
|
||||||
|
// and its error is shown exactly once.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // executes the CLI in-process and sets the environment
|
||||||
|
func TestUsageOnlyForCallErrors(t *testing.T) {
|
||||||
|
// No vault in the state directory, so `get x` fails while running.
|
||||||
|
env := map[string]string{secret.EnvStateDir: t.TempDir()}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
call string
|
||||||
|
wantUsage bool
|
||||||
|
}{
|
||||||
|
{call: "get", wantUsage: true},
|
||||||
|
{call: "get x y", wantUsage: true},
|
||||||
|
{call: "get --no-such-flag x", wantUsage: true},
|
||||||
|
{call: "generate secret x --length abc", wantUsage: true},
|
||||||
|
{call: "import x", wantUsage: true},
|
||||||
|
{call: "get x", wantUsage: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
output, err := cli.ExecuteCommandInProcess(strings.Fields(tt.call), "", env)
|
||||||
|
require.Error(t, err, "%q should fail", tt.call)
|
||||||
|
|
||||||
|
assert.Equal(t, 1, strings.Count(output, err.Error()),
|
||||||
|
"%q should show its error once:\n%s", tt.call, output)
|
||||||
|
assert.Equal(t, tt.wantUsage, strings.Contains(output, usageHeading),
|
||||||
|
"usage shown for %q:\n%s", tt.call, output)
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
-146
@@ -5,7 +5,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -24,7 +23,6 @@ import (
|
|||||||
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(
|
||||||
@@ -32,8 +30,6 @@ var (
|
|||||||
errPassphraseEnvNotSet = errors.New(
|
errPassphraseEnvNotSet = errors.New(
|
||||||
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||||
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||||
errVaultContainsSecrets = errors.New(
|
|
||||||
"contains secrets; use --force to remove")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func newVaultCmd() *cobra.Command {
|
func newVaultCmd() *cobra.Command {
|
||||||
@@ -85,6 +81,9 @@ func newVaultCreateCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.CreateVault(cmd, args[0])
|
return cli.CreateVault(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -136,6 +135,9 @@ func newVaultImportCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.VaultImport(cmd, vaultName)
|
return cli.VaultImport(cmd, vaultName)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -151,9 +153,12 @@ func newVaultRemoveCmd() *cobra.Command {
|
|||||||
Use: "remove <name>",
|
Use: "remove <name>",
|
||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a vault",
|
Short: "Remove a vault",
|
||||||
Long: `Remove a vault. Requires --force if the vault contains ` +
|
Long: `Remove a vault and all its secrets. Asks for ` +
|
||||||
`secrets. Will automatically switch to another vault if ` +
|
`confirmation first, naming how many secrets the vault ` +
|
||||||
`removing the currently selected one.`,
|
`holds; when stdin is not a terminal, fails unless --force ` +
|
||||||
|
`is given. Will automatically switch to another vault if ` +
|
||||||
|
`removing the currently selected one. The last vault ` +
|
||||||
|
`cannot be removed.`,
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -168,7 +173,8 @@ func newVaultRemoveCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolP("force", "f", false, "Force removal even if vault contains secrets")
|
cmd.Flags().BoolP("force", "f", false,
|
||||||
|
"Remove without asking for confirmation, even a vault that contains secrets")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -228,28 +234,14 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// setMnemonicEnv sets the mnemonic environment variable and returns a
|
// resolvePassphrase returns the unlock passphrase from the environment,
|
||||||
// function that restores the previous value
|
// cli.UnlockPassphrase, or prompts the user for it with confirmation. The
|
||||||
func setMnemonicEnv(mnemonicStr string) func() {
|
// returned cleanup function must be deferred by the caller.
|
||||||
originalMnemonic := os.Getenv(secret.EnvMnemonic)
|
func (cli *Instance) resolvePassphrase() (*memguard.LockedBuffer, func(), error) {
|
||||||
_ = os.Setenv(secret.EnvMnemonic, mnemonicStr)
|
if cli.UnlockPassphrase != nil {
|
||||||
|
|
||||||
return func() {
|
|
||||||
if originalMnemonic != "" {
|
|
||||||
_ = os.Setenv(secret.EnvMnemonic, originalMnemonic)
|
|
||||||
} else {
|
|
||||||
_ = os.Unsetenv(secret.EnvMnemonic)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolvePassphrase returns the unlock passphrase from the environment or
|
|
||||||
// prompts the user for it with confirmation
|
|
||||||
func resolvePassphrase() (*memguard.LockedBuffer, error) {
|
|
||||||
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
return memguard.NewBufferFromBytes([]byte(envPassphrase)), nil
|
return cli.UnlockPassphrase, func() {}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Prompting user for unlock passphrase")
|
secret.Debug("Prompting user for unlock passphrase")
|
||||||
@@ -257,10 +249,10 @@ func resolvePassphrase() (*memguard.LockedBuffer, error) {
|
|||||||
// Use secure passphrase input with confirmation
|
// Use secure passphrase input with confirmation
|
||||||
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
passphraseBuffer, err := readSecurePassphrase("Enter passphrase for unlocker: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return passphraseBuffer, nil
|
return passphraseBuffer, passphraseBuffer.Destroy, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault
|
// CreateVault creates a new vault
|
||||||
@@ -273,30 +265,13 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Get or prompt for mnemonic
|
mnemonic, cleanupMnemonic, err := cli.promptMnemonic()
|
||||||
var mnemonicStr string
|
if err != nil {
|
||||||
|
return err
|
||||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
|
||||||
secret.Debug("Using mnemonic from environment variable")
|
|
||||||
|
|
||||||
mnemonicStr = envMnemonic
|
|
||||||
} else {
|
|
||||||
secret.Debug("Prompting user for mnemonic phrase")
|
|
||||||
// Read mnemonic securely without echo
|
|
||||||
mnemonicBuffer, err := secret.ReadPassphrase(
|
|
||||||
"Enter your BIP39 mnemonic phrase: ")
|
|
||||||
if err != nil {
|
|
||||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to read mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
defer mnemonicBuffer.Destroy()
|
|
||||||
|
|
||||||
mnemonicStr = mnemonicBuffer.String()
|
|
||||||
|
|
||||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
|
||||||
}
|
}
|
||||||
|
defer cleanupMnemonic()
|
||||||
|
|
||||||
|
mnemonicStr := mnemonic.String()
|
||||||
if mnemonicStr == "" {
|
if mnemonicStr == "" {
|
||||||
return errMnemonicEmpty
|
return errMnemonicEmpty
|
||||||
}
|
}
|
||||||
@@ -309,51 +284,34 @@ func (cli *Instance) CreateVault(cmd *cobra.Command, name string) error {
|
|||||||
return errInvalidMnemonicPhrase
|
return errInvalidMnemonicPhrase
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set mnemonic in environment for CreateVault to use
|
// Ask for the unlocker passphrase before creating the vault, so that
|
||||||
restoreMnemonicEnv := setMnemonicEnv(mnemonicStr)
|
// stopping at the prompt leaves no vault without an unlocker behind
|
||||||
defer restoreMnemonicEnv()
|
passphraseBuffer, cleanupPassphrase, err := cli.resolvePassphrase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanupPassphrase()
|
||||||
|
|
||||||
// Create the vault - it will handle key derivation internally
|
// Create the vault with its passphrase unlocker
|
||||||
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name)
|
vlt, err := vault.CreateVault(cli.fs, cli.stateDir, name,
|
||||||
|
mnemonic, passphraseBuffer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault metadata to retrieve the derivation index
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
vaultDir := filepath.Join(cli.stateDir, "vaults.d", name)
|
|
||||||
|
|
||||||
metadata, err := vault.LoadVaultMetadata(cli.fs, vaultDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load vault metadata: %w", err)
|
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the long-term key using the same index that CreateVault used
|
unlocker, err := vlt.GetCurrentUnlocker()
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonicStr, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
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)
|
|
||||||
|
|
||||||
// Get or prompt for passphrase
|
|
||||||
passphraseBuffer, err := resolvePassphrase()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
|
|
||||||
// Create passphrase-protected unlocker
|
|
||||||
secret.Debug("Creating passphrase-protected unlocker")
|
|
||||||
|
|
||||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
cmd.Printf("Created vault '%s'\n", vlt.GetName())
|
||||||
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
cmd.Printf("Long-term public key: %s\n", ltIdentity.Recipient().String())
|
||||||
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
cmd.Printf("Unlocker ID: %s\n", unlocker.GetID())
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -394,30 +352,35 @@ func (cli *Instance) vaultImportPreflight(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
vaultName, errVaultDoesNotExist)
|
vaultName, vault.ErrVaultNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if vault already has a public key
|
// Check if vault already has a public key
|
||||||
pubKeyPath := vaultDir + "/pub.age"
|
pubKeyPath := vaultDir + "/pub.age"
|
||||||
|
|
||||||
_, err = cli.fs.Stat(pubKeyPath)
|
exists, err = afero.Exists(cli.fs, pubKeyPath)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
return "", "", "", fmt.Errorf("vault '%s' %w",
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
vaultName, errVaultHasLongTermKey)
|
vaultName, errVaultHasLongTermKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get mnemonic from environment
|
// Get mnemonic from environment
|
||||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
if cli.Mnemonic == nil {
|
||||||
if mnemonic == "" {
|
|
||||||
return "", "", "", errMnemonicEnvNotSet
|
return "", "", "", errMnemonicEnvNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mnemonic := cli.Mnemonic.String()
|
||||||
|
|
||||||
// Validate the mnemonic
|
// Validate the mnemonic
|
||||||
mnemonicWords := strings.Fields(mnemonic)
|
mnemonicWords := strings.Fields(mnemonic)
|
||||||
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
if !bip39.IsMnemonicValid(mnemonic) {
|
if !bip39.IsMnemonicValid(mnemonic) {
|
||||||
return "", "", "", errInvalidMnemonic
|
return "", "", "", errInvalidMnemonicPhrase
|
||||||
}
|
}
|
||||||
|
|
||||||
return vaultDir, pubKeyPath, mnemonic, nil
|
return vaultDir, pubKeyPath, mnemonic, nil
|
||||||
@@ -457,6 +420,11 @@ func updateVaultImportMetadata(
|
|||||||
// VaultImport imports a mnemonic into a specific vault, holding the state
|
// VaultImport imports a mnemonic into a specific vault, holding the state
|
||||||
// directory lock while importMnemonic runs
|
// directory lock while importMnemonic runs
|
||||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||||
|
err := vault.ValidateVaultName(vaultName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -529,17 +497,13 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get passphrase from environment variable
|
// Get passphrase from environment variable
|
||||||
passphraseStr := os.Getenv(secret.EnvUnlockPassphrase)
|
passphraseBuffer := cli.UnlockPassphrase
|
||||||
if passphraseStr == "" {
|
if passphraseBuffer == nil {
|
||||||
return errPassphraseEnvNotSet
|
return errPassphraseEnvNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Using unlock passphrase from environment variable")
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
// Create secure buffer for passphrase
|
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
|
|
||||||
// Unlock the vault with the derived long-term key
|
// Unlock the vault with the derived long-term key
|
||||||
vlt.Unlock(ltIdentity)
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
@@ -560,18 +524,27 @@ func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// vaultHasSecrets reports whether the vault directory contains any secrets
|
// countVaultSecrets returns the number of secrets in the vault directory
|
||||||
func (cli *Instance) vaultHasSecrets(vaultDir string) bool {
|
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
exists, _ := afero.DirExists(cli.fs, secretsDir)
|
exists, err := afero.DirExists(cli.fs, secretsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
|
||||||
|
secretsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return false
|
return 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
entries, err := afero.ReadDir(cli.fs, secretsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read secrets directory %s: %w",
|
||||||
|
secretsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
return err == nil && len(entries) > 0
|
return len(entries), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// switchAwayFromVault selects another vault as current before removal
|
// switchAwayFromVault selects another vault as current before removal
|
||||||
@@ -600,74 +573,107 @@ func (cli *Instance) switchAwayFromVault(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveVault removes a vault with safety checks
|
// RemoveVault removes a vault and all its secrets, after asking the user
|
||||||
|
// to confirm unless force is set.
|
||||||
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
err := vault.ValidateVaultName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var found vaultToRemove
|
||||||
|
|
||||||
|
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
found, err = cli.findVaultToRemove(name)
|
||||||
|
|
||||||
|
return found.question, err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// 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 := cli.vaultHasSecrets(vaultDir)
|
|
||||||
|
|
||||||
// Require --force if vault has secrets
|
|
||||||
if hasSecrets && !force {
|
|
||||||
return fmt.Errorf("vault '%s' %w", name, errVaultContainsSecrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If removing current vault, switch to another vault first
|
// If removing current vault, switch to another vault first
|
||||||
if isCurrentVault {
|
if found.isCurrent {
|
||||||
err = cli.switchAwayFromVault(cmd, vaults, name)
|
err = cli.switchAwayFromVault(cmd, found.vaults, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the vault directory
|
// Remove the vault directory
|
||||||
err = secret.RemoveDirAtomic(cli.fs, vaultDir)
|
err = secret.RemoveDirAtomic(cli.fs, found.dir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove vault directory: %w", err)
|
return fmt.Errorf("failed to remove vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Printf("Removed vault '%s'\n", name)
|
cmd.Printf("Removed vault '%s'\n", name)
|
||||||
|
|
||||||
if hasSecrets {
|
if found.secrets > 0 {
|
||||||
cmd.Printf("Warning: Vault contained secrets that have been " +
|
cmd.Printf("Warning: Vault contained secrets that have been " +
|
||||||
"permanently deleted\n")
|
"permanently deleted\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vaultToRemove is what removing a vault removes, as findVaultToRemove
|
||||||
|
// found it.
|
||||||
|
type vaultToRemove struct {
|
||||||
|
// dir is the vault's directory, which holds all its secrets.
|
||||||
|
dir string
|
||||||
|
secrets int
|
||||||
|
// vaults lists every vault, this one included, and isCurrent is set
|
||||||
|
// when this one is the current vault.
|
||||||
|
vaults []string
|
||||||
|
isCurrent bool
|
||||||
|
// question names what is removed, for the user to confirm.
|
||||||
|
question string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findVaultToRemove checks that the vault exists and is not the last one,
|
||||||
|
// and counts its secrets.
|
||||||
|
func (cli *Instance) findVaultToRemove(name string) (vaultToRemove, error) {
|
||||||
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return vaultToRemove{}, fmt.Errorf("failed to list vaults: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(vaults, name) {
|
||||||
|
return vaultToRemove{},
|
||||||
|
fmt.Errorf("vault '%s' %w", name, 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
|
||||||
|
}
|
||||||
|
|||||||
+70
-29
@@ -23,7 +23,6 @@ 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")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +53,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
return cli.ListVersions(cmd, args[0])
|
return cli.ListVersions(cmd, args[0])
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -86,7 +88,8 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
Aliases: []string{"rm"},
|
Aliases: []string{"rm"},
|
||||||
Short: "Remove a specific version of a secret",
|
Short: "Remove a specific version of a secret",
|
||||||
Long: "Remove a specific version of a secret. Cannot remove the " +
|
Long: "Remove a specific version of a secret. Cannot remove the " +
|
||||||
"current version.",
|
"current version. Asks for confirmation first; when stdin " +
|
||||||
|
"is not a terminal, fails unless --force is given.",
|
||||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||||
ValidArgsFunction: func(
|
ValidArgsFunction: func(
|
||||||
cmd *cobra.Command, args []string, toComplete string,
|
cmd *cobra.Command, args []string, toComplete string,
|
||||||
@@ -99,10 +102,15 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
|||||||
return nil, cobra.ShellCompDirectiveNoFileComp
|
return nil, cobra.ShellCompDirectiveNoFileComp
|
||||||
},
|
},
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return cli.RemoveVersion(cmd, args[0], args[1])
|
force, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
|
return cli.RemoveVersion(cmd, args[0], args[1], force)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeCmd.Flags().BoolP("force", "f", false,
|
||||||
|
"Remove without asking for confirmation")
|
||||||
|
|
||||||
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
versionCmd.AddCommand(listCmd, promoteCmd, removeCmd)
|
||||||
|
|
||||||
return versionCmd
|
return versionCmd
|
||||||
@@ -147,7 +155,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, errSecretNotFound)
|
return fmt.Errorf("secret '%s' %w", secretName, vault.ErrSecretNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
// List all versions
|
// List all versions
|
||||||
@@ -172,6 +180,8 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
|||||||
currentVersion = ""
|
currentVersion = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Get long-term key for decrypting metadata
|
// Get long-term key for decrypting metadata
|
||||||
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -278,7 +288,7 @@ func (cli *Instance) PromoteVersion(
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("version '%s' %w '%s'",
|
return fmt.Errorf("version '%s' %w '%s'",
|
||||||
version, errVersionNotFound, secretName)
|
version, vault.ErrVersionNotFound, secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the current symlink using the proper function
|
// Update the current symlink using the proper function
|
||||||
@@ -292,30 +302,62 @@ func (cli *Instance) PromoteVersion(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveVersion removes a specific version of a secret
|
// RemoveVersion removes a specific version of a secret, after asking the
|
||||||
|
// user to confirm unless force is set.
|
||||||
func (cli *Instance) RemoveVersion(
|
func (cli *Instance) RemoveVersion(
|
||||||
cmd *cobra.Command, secretName string, version string,
|
cmd *cobra.Command, secretName string, version string, force bool,
|
||||||
) error {
|
) error {
|
||||||
err := vault.ValidateSecretName(secretName)
|
err := vault.ValidateSecretName(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
var found versionToRemove
|
||||||
|
|
||||||
|
release, err := cli.askThenLock(cmd, force, func() (string, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
found, err = cli.findVersionToRemove(secretName, version)
|
||||||
|
|
||||||
|
return found.question, err
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
// Get current vault
|
err = secret.RemoveDirAtomic(cli.fs, found.dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// versionToRemove is what removing a version removes, as
|
||||||
|
// findVersionToRemove found it.
|
||||||
|
type versionToRemove struct {
|
||||||
|
// dir is the version's directory.
|
||||||
|
dir string
|
||||||
|
// question names what is removed, for the user to confirm.
|
||||||
|
question string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findVersionToRemove checks that the version exists in the secret in the
|
||||||
|
// current vault and is not its current version.
|
||||||
|
func (cli *Instance) findVersionToRemove(
|
||||||
|
secretName, version string,
|
||||||
|
) (versionToRemove, error) {
|
||||||
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
vlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return versionToRemove{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return versionToRemove{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the encoded secret name
|
// Get the encoded secret name
|
||||||
@@ -325,45 +367,44 @@ func (cli *Instance) RemoveVersion(
|
|||||||
// Check if secret exists
|
// Check if secret exists
|
||||||
exists, err := afero.DirExists(cli.fs, secretDir)
|
exists, err := afero.DirExists(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if secret exists: %w", err)
|
return versionToRemove{},
|
||||||
|
fmt.Errorf("failed to check if secret exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
|
return versionToRemove{},
|
||||||
|
fmt.Errorf("secret '%s' %w", secretName, 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 fmt.Errorf("failed to check if version exists: %w", err)
|
return versionToRemove{},
|
||||||
|
fmt.Errorf("failed to check if version exists: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return fmt.Errorf("version '%s' %w '%s'",
|
return versionToRemove{}, fmt.Errorf("version '%s' %w '%s'",
|
||||||
version, errVersionNotFound, secretName)
|
version, vault.ErrVersionNotFound, secretName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current version
|
// Get current version
|
||||||
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
currentVersion, err := secret.GetCurrentVersion(cli.fs, secretDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get current version: %w", err)
|
return versionToRemove{},
|
||||||
|
fmt.Errorf("failed to get current version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow removing the current version
|
// Don't allow removing the current version
|
||||||
if version == currentVersion {
|
if version == currentVersion {
|
||||||
return fmt.Errorf("cannot remove the current version '%s'; %w",
|
return versionToRemove{}, fmt.Errorf(
|
||||||
|
"cannot remove the current version '%s'; %w",
|
||||||
version, errCannotRemoveCurrentVersion)
|
version, errCannotRemoveCurrentVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove the version directory
|
return versionToRemove{
|
||||||
versionDir := filepath.Join(secretDir, "versions", version)
|
dir: filepath.Join(secretDir, "versions", version),
|
||||||
|
question: fmt.Sprintf("Permanently remove version %s of secret "+
|
||||||
err = secret.RemoveDirAtomic(cli.fs, versionDir)
|
"'%s' from vault '%s'?", version, secretName, vlt.GetName()),
|
||||||
if err != nil {
|
}, nil
|
||||||
return fmt.Errorf("failed to remove version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Printf("Removed version %s of secret '%s'\n", version, secretName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
// - TestPromoteVersionCommand: Tests `secret version promote` command
|
||||||
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
// - TestPromoteNonExistentVersion: Tests error handling for invalid promotion
|
||||||
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
// - TestGetSecretWithVersion: Tests `secret get --version` flag functionality
|
||||||
|
// - TestGetSecretWritesBinaryValue: Tests `secret get` output of binary values
|
||||||
// - TestVersionCommandStructure: Tests command structure and help text
|
// - TestVersionCommandStructure: Tests command structure and help text
|
||||||
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
// - TestListVersionsEmptyOutput: Tests edge case with no versions
|
||||||
//
|
//
|
||||||
@@ -23,6 +24,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
@@ -43,6 +45,17 @@ const (
|
|||||||
testStateDir = "/test/state"
|
testStateDir = "/test/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
||||||
|
// destroyed when the test ends.
|
||||||
|
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
t.Cleanup(mnemonic.Destroy)
|
||||||
|
|
||||||
|
return mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
// Helper function to add a version of the "test/secret" secret to the
|
// Helper function to add a version of the "test/secret" secret to the
|
||||||
// vault with proper buffer protection
|
// vault with proper buffer protection
|
||||||
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
||||||
@@ -59,11 +72,9 @@ func addTestSecret(t *testing.T, vlt *vault.Vault, value []byte, force bool) {
|
|||||||
func setupTestVault(t *testing.T, fs afero.Fs) {
|
func setupTestVault(t *testing.T, fs afero.Fs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Set mnemonic for testing
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default")
|
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -81,11 +92,13 @@ func setupTestVault(t *testing.T, fs afero.Fs) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
|
||||||
func TestListVersionsCommand(t *testing.T) {
|
func TestListVersionsCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
cli.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs)
|
||||||
@@ -94,6 +107,8 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = cli.Mnemonic
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -137,8 +152,9 @@ func TestListVersionsCommand(t *testing.T) {
|
|||||||
assert.Equal(t, 2, versionLines)
|
assert.Equal(t, 2, versionLines)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
|
||||||
func TestListVersionsNonExistentSecret(t *testing.T) {
|
func TestListVersionsNonExistentSecret(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
@@ -155,12 +171,12 @@ 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.Error(t, err)
|
require.ErrorIs(t, err, vault.ErrSecretNotFound)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
|
||||||
func TestPromoteVersionCommand(t *testing.T) {
|
func TestPromoteVersionCommand(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
@@ -172,6 +188,8 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -188,7 +206,10 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
// Current should be version-2
|
// Current should be version-2
|
||||||
value, err := vlt.GetSecret("test/secret")
|
value, err := vlt.GetSecret("test/secret")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2"), value)
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-2"), value.Bytes())
|
||||||
|
|
||||||
// Promote first version
|
// Promote first version
|
||||||
firstVersion := versions[1] // Older version
|
firstVersion := versions[1] // Older version
|
||||||
@@ -211,13 +232,17 @@ func TestPromoteVersionCommand(t *testing.T) {
|
|||||||
assert.Contains(t, outputStr, firstVersion)
|
assert.Contains(t, outputStr, firstVersion)
|
||||||
|
|
||||||
// Verify current is now version-1
|
// Verify current is now version-1
|
||||||
value, err = vlt.GetSecret("test/secret")
|
promoted, err := vlt.GetSecret("test/secret")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-1"), value)
|
|
||||||
|
defer promoted.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-1"), promoted.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
|
||||||
func TestPromoteNonExistentVersion(t *testing.T) {
|
func TestPromoteNonExistentVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
@@ -240,15 +265,16 @@ 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.Error(t, err)
|
require.ErrorIs(t, err, vault.ErrVersionNotFound)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
|
||||||
func TestGetSecretWithVersion(t *testing.T) {
|
func TestGetSecretWithVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
cli.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
// Set up vault with long-term key
|
// Set up vault with long-term key
|
||||||
setupTestVault(t, fs)
|
setupTestVault(t, fs)
|
||||||
@@ -257,6 +283,8 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
vlt, err := vault.GetCurrentVault(fs, stateDir)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vlt.Mnemonic = cli.Mnemonic
|
||||||
|
|
||||||
addTestSecret(t, vlt, []byte("version-1"), false)
|
addTestSecret(t, vlt, []byte("version-1"), false)
|
||||||
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
time.Sleep(10 * time.Millisecond)
|
||||||
@@ -290,6 +318,48 @@ func TestGetSecretWithVersion(t *testing.T) {
|
|||||||
assert.Equal(t, "version-1", buf.String())
|
assert.Equal(t, "version-1", buf.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetSecretWritesBinaryValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
cli := NewCLIInstanceWithStateDir(fs, testStateDir)
|
||||||
|
cli.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
setupTestVault(t, fs)
|
||||||
|
|
||||||
|
vlt, err := vault.GetCurrentVault(fs, testStateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
value := []byte{0x00, 'a', 0xff, 0xfe, 0x00, 0xc3, 0x28, 'z', 0x00}
|
||||||
|
require.False(t, utf8.Valid(value))
|
||||||
|
// A copy, since storing a value wipes the slice it came from
|
||||||
|
addTestSecret(t, vlt, bytes.Clone(value), false)
|
||||||
|
|
||||||
|
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, 1)
|
||||||
|
|
||||||
|
cmd := newRootCmd()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.SetOut(&buf)
|
||||||
|
|
||||||
|
// Each writes exactly the stored bytes, with no trailing newline
|
||||||
|
err = cli.GetSecret(cmd, "test/secret")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, value, buf.Bytes())
|
||||||
|
|
||||||
|
buf.Reset()
|
||||||
|
|
||||||
|
err = cli.GetSecretWithVersion(cmd, "test/secret", versions[0])
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, value, buf.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // reads process environment to determine the state dir
|
//nolint:paralleltest // reads process environment to determine the state dir
|
||||||
func TestVersionCommandStructure(t *testing.T) {
|
func TestVersionCommandStructure(t *testing.T) {
|
||||||
// Test that version commands are properly structured
|
// Test that version commands are properly structured
|
||||||
@@ -313,8 +383,9 @@ func TestVersionCommandStructure(t *testing.T) {
|
|||||||
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
assert.Equal(t, "Promote a specific version to current", promoteCmd.Short)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv via setupTestVault
|
|
||||||
func TestListVersionsEmptyOutput(t *testing.T) {
|
func TestListVersionsEmptyOutput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
stateDir := testStateDir
|
stateDir := testStateDir
|
||||||
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
cli := NewCLIInstanceWithStateDir(fs, stateDir)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ package macse
|
|||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
)
|
)
|
||||||
@@ -38,10 +39,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
// CreateKey creates a new P-256 non-exportable key in the Secure Enclave via sc_auth.
|
||||||
// Returns the uncompressed public key bytes (65 bytes) and the identity hash (for deletion).
|
// Returns the uncompressed public key bytes (65 bytes) and the identity hash
|
||||||
|
// (for deletion). If getting the public key fails, CreateKey deletes the key
|
||||||
|
// again; a failure to delete is returned along with the first error.
|
||||||
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
||||||
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
|
||||||
pubKeyLen := C.int(p256UncompressedKeySize)
|
|
||||||
var hashBuf [hashBufferSize]C.char
|
var hashBuf [hashBufferSize]C.char
|
||||||
var errBuf [errorBufferSize]C.char
|
var errBuf [errorBufferSize]C.char
|
||||||
|
|
||||||
@@ -49,7 +50,6 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
defer C.free(unsafe.Pointer(cLabel)) //nolint:nlreturn // CGo free pattern
|
||||||
|
|
||||||
result := C.se_create_key(cLabel,
|
result := C.se_create_key(cLabel,
|
||||||
&pubKeyBuf[0], &pubKeyLen,
|
|
||||||
&hashBuf[0], C.int(hashBufferSize),
|
&hashBuf[0], C.int(hashBufferSize),
|
||||||
&errBuf[0], C.int(errorBufferSize))
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
@@ -57,9 +57,30 @@ func CreateKey(label string) (publicKey []byte, hash string, err error) {
|
|||||||
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, "", fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen) //nolint:nlreturn // CGo result extraction
|
|
||||||
h := C.GoString(&hashBuf[0])
|
h := C.GoString(&hashBuf[0])
|
||||||
|
|
||||||
|
pubKeyBuf := make([]C.uint8_t, p256UncompressedKeySize)
|
||||||
|
pubKeyLen := C.int(p256UncompressedKeySize)
|
||||||
|
|
||||||
|
result = C.se_copy_public_key(cLabel,
|
||||||
|
&pubKeyBuf[0], &pubKeyLen,
|
||||||
|
&errBuf[0], C.int(errorBufferSize))
|
||||||
|
|
||||||
|
if result != 0 {
|
||||||
|
err = fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
|
|
||||||
|
deleteErr := DeleteKey(h)
|
||||||
|
if deleteErr != nil {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("failed to delete key %s: %w", label, deleteErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:nlreturn // CGo result extraction
|
||||||
|
pk := C.GoBytes(unsafe.Pointer(&pubKeyBuf[0]), pubKeyLen)
|
||||||
|
|
||||||
return pk, h, nil
|
return pk, h, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +104,8 @@ func Encrypt(label string, plaintext []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen) //nolint:nlreturn // CGo result extraction
|
//nolint:nlreturn // CGo result extraction
|
||||||
|
out := C.GoBytes(unsafe.Pointer(&ciphertextBuf[0]), ciphertextLen)
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -107,7 +129,8 @@ func Decrypt(label string, ciphertext []byte) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
return nil, fmt.Errorf("secure enclave: %s", C.GoString(&errBuf[0]))
|
||||||
}
|
}
|
||||||
|
|
||||||
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen) //nolint:nlreturn // CGo result extraction
|
//nolint:nlreturn // CGo result extraction
|
||||||
|
out := C.GoBytes(unsafe.Pointer(&plaintextBuf[0]), plaintextLen)
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
//go:build !darwin
|
//go:build !darwin || !cgo
|
||||||
|
|
||||||
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
// Package macse provides Go bindings for macOS Secure Enclave operations.
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var errNotSupported = errors.New("secure enclave is only supported on macOS")
|
var errNotSupported = errors.New("secure enclave needs a macOS build with cgo")
|
||||||
|
|
||||||
// CreateKey is not supported on non-darwin platforms.
|
// CreateKey fails: the Secure Enclave needs a macOS build with cgo.
|
||||||
func CreateKey(_ string) ([]byte, string, error) {
|
func CreateKey(_ string) ([]byte, string, error) {
|
||||||
return nil, "", errNotSupported
|
return nil, "", errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt is not supported on non-darwin platforms.
|
// Encrypt fails: the Secure Enclave needs a macOS build with cgo.
|
||||||
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
func Encrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
return nil, errNotSupported
|
return nil, errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt is not supported on non-darwin platforms.
|
// Decrypt fails: the Secure Enclave needs a macOS build with cgo.
|
||||||
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
func Decrypt(_ string, _ []byte) ([]byte, error) {
|
||||||
return nil, errNotSupported
|
return nil, errNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteKey is not supported on non-darwin platforms.
|
// DeleteKey fails: the Secure Enclave needs a macOS build with cgo.
|
||||||
func DeleteKey(_ string) error {
|
func DeleteKey(_ string) error {
|
||||||
return errNotSupported
|
return errNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
//go:build darwin
|
//go:build darwin && cgo
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package macse
|
package macse
|
||||||
|
|
||||||
@@ -45,7 +44,8 @@ func TestCreateAndDeleteKey(t *testing.T) {
|
|||||||
|
|
||||||
// Verify valid uncompressed P-256 public key
|
// Verify valid uncompressed P-256 public key
|
||||||
if len(pubKey) != p256UncompressedKeySize {
|
if len(pubKey) != p256UncompressedKeySize {
|
||||||
t.Fatalf("expected public key length %d, got %d", p256UncompressedKeySize, len(pubKey))
|
t.Fatalf("expected public key length %d, got %d",
|
||||||
|
p256UncompressedKeySize, len(pubKey))
|
||||||
}
|
}
|
||||||
|
|
||||||
if pubKey[0] != 0x04 {
|
if pubKey[0] != 0x04 {
|
||||||
@@ -83,7 +83,8 @@ func TestEncryptDecryptRoundTrip(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Test data simulating an age private key
|
// Test data simulating an age private key
|
||||||
plaintext := []byte("AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
plaintext := []byte("AGE-SECRET-KEY-1" +
|
||||||
|
"QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ")
|
||||||
|
|
||||||
// Encrypt
|
// Encrypt
|
||||||
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
ciphertext, err := Encrypt(testKeyLabel, plaintext)
|
||||||
|
|||||||
@@ -5,20 +5,30 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth.
|
// se_create_key creates a new P-256 key in the Secure Enclave via sc_auth and
|
||||||
|
// finds its identity hash. If the hash cannot be found, the key exists but
|
||||||
|
// se_create_key fails, with an error naming the label.
|
||||||
// label: unique identifier for the CTK identity (UTF-8 C string)
|
// label: unique identifier for the CTK identity (UTF-8 C string)
|
||||||
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
|
||||||
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
|
||||||
// hash_out: output buffer for the identity hash (for deletion)
|
// hash_out: output buffer for the identity hash (for deletion)
|
||||||
// hash_out_len: size of hash_out buffer
|
// hash_out_len: size of hash_out buffer
|
||||||
// error_out: output buffer for error message
|
// error_out: output buffer for error message
|
||||||
// error_out_len: size of error_out buffer
|
// error_out_len: size of error_out buffer
|
||||||
// Returns 0 on success, -1 on failure.
|
// Returns 0 on success, -1 on failure.
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len);
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
|
// se_copy_public_key copies the public key of a CTK identity.
|
||||||
|
// label: label of the CTK identity
|
||||||
|
// pub_key_out: output buffer for the uncompressed public key (65 bytes for P-256)
|
||||||
|
// pub_key_len: on input, size of pub_key_out; on output, actual size written
|
||||||
|
// error_out: output buffer for error message
|
||||||
|
// error_out_len: size of error_out buffer
|
||||||
|
// Returns 0 on success, -1 on failure.
|
||||||
|
int se_copy_public_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *error_out, int error_out_len);
|
||||||
|
|
||||||
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
// se_encrypt encrypts data using the SE-backed public key (ECIES).
|
||||||
// label: label of the CTK identity whose public key to use
|
// label: label of the CTK identity whose public key to use
|
||||||
// plaintext: data to encrypt
|
// plaintext: data to encrypt
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ static SecKeyRef lookup_ctk_private_key(const char *label, char *error_out, int
|
|||||||
}
|
}
|
||||||
|
|
||||||
int se_create_key(const char *label,
|
int se_create_key(const char *label,
|
||||||
uint8_t *pub_key_out, int *pub_key_len,
|
|
||||||
char *hash_out, int hash_out_len,
|
char *hash_out, int hash_out_len,
|
||||||
char *error_out, int error_out_len) {
|
char *error_out, int error_out_len) {
|
||||||
@autoreleasepool {
|
@autoreleasepool {
|
||||||
@@ -87,7 +86,56 @@ int se_create_key(const char *label,
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retrieve the public key from the created identity
|
// Get the identity hash, which deleting the key needs, by parsing
|
||||||
|
// sc_auth list output
|
||||||
|
hash_out[0] = '\0';
|
||||||
|
NSTask *listTask = [[NSTask alloc] init];
|
||||||
|
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
||||||
|
listTask.arguments = @[@"list-ctk-identities"];
|
||||||
|
|
||||||
|
NSPipe *listPipe = [NSPipe pipe];
|
||||||
|
listTask.standardOutput = listPipe;
|
||||||
|
listTask.standardError = [NSPipe pipe];
|
||||||
|
|
||||||
|
if ([listTask launchAndReturnError:&nsError]) {
|
||||||
|
[listTask waitUntilExit];
|
||||||
|
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
||||||
|
NSString *listStr = [[NSString alloc] initWithData:listData
|
||||||
|
encoding:NSUTF8StringEncoding];
|
||||||
|
|
||||||
|
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
||||||
|
if ([line containsString:labelStr]) {
|
||||||
|
NSMutableArray *tokens = [NSMutableArray array];
|
||||||
|
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
||||||
|
[NSCharacterSet whitespaceCharacterSet]]) {
|
||||||
|
if (part.length > 0) {
|
||||||
|
[tokens addObject:part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (tokens.count > 1) {
|
||||||
|
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hash_out[0] == '\0') {
|
||||||
|
NSString *msg = [NSString stringWithFormat:
|
||||||
|
@"created key '%s' but found no hash for it in sc_auth list-ctk-identities",
|
||||||
|
label];
|
||||||
|
snprintf_error(error_out, error_out_len, msg);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int se_copy_public_key(const char *label,
|
||||||
|
uint8_t *pub_key_out, int *pub_key_len,
|
||||||
|
char *error_out, int error_out_len) {
|
||||||
|
@autoreleasepool {
|
||||||
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
SecKeyRef privateKey = lookup_ctk_private_key(label, error_out, error_out_len);
|
||||||
if (!privateKey) {
|
if (!privateKey) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -126,39 +174,6 @@ int se_create_key(const char *label,
|
|||||||
*pub_key_len = (int)length;
|
*pub_key_len = (int)length;
|
||||||
CFRelease(pubKeyData);
|
CFRelease(pubKeyData);
|
||||||
|
|
||||||
// Get the identity hash by parsing sc_auth list output
|
|
||||||
hash_out[0] = '\0';
|
|
||||||
NSTask *listTask = [[NSTask alloc] init];
|
|
||||||
listTask.executableURL = [NSURL fileURLWithPath:@"/usr/sbin/sc_auth"];
|
|
||||||
listTask.arguments = @[@"list-ctk-identities"];
|
|
||||||
|
|
||||||
NSPipe *listPipe = [NSPipe pipe];
|
|
||||||
listTask.standardOutput = listPipe;
|
|
||||||
listTask.standardError = [NSPipe pipe];
|
|
||||||
|
|
||||||
if ([listTask launchAndReturnError:&nsError]) {
|
|
||||||
[listTask waitUntilExit];
|
|
||||||
NSData *listData = [listPipe.fileHandleForReading readDataToEndOfFile];
|
|
||||||
NSString *listStr = [[NSString alloc] initWithData:listData
|
|
||||||
encoding:NSUTF8StringEncoding];
|
|
||||||
|
|
||||||
for (NSString *line in [listStr componentsSeparatedByString:@"\n"]) {
|
|
||||||
if ([line containsString:labelStr]) {
|
|
||||||
NSMutableArray *tokens = [NSMutableArray array];
|
|
||||||
for (NSString *part in [line componentsSeparatedByCharactersInSet:
|
|
||||||
[NSCharacterSet whitespaceCharacterSet]]) {
|
|
||||||
if (part.length > 0) {
|
|
||||||
[tokens addObject:part];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (tokens.count > 1) {
|
|
||||||
snprintf(hash_out, hash_out_len, "%s", [tokens[1] UTF8String]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// tempNamePart is in the name of every temporary file WriteFileAtomic makes,
|
||||||
|
// ".NAME.tmp-123", and every temporary directory TempDirFor makes, ".tmp-123".
|
||||||
|
const tempNamePart = ".tmp-"
|
||||||
|
|
||||||
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
// WriteFileAtomic replaces the file at path with data so that a reader, or
|
||||||
// a crash at any moment, finds either the old content or the new, never a
|
// a crash at any moment, finds either the old content or the new, never a
|
||||||
// partial file. The data goes into a temporary file that afero.TempFile
|
// partial file. The data goes into a temporary file that afero.TempFile
|
||||||
@@ -15,7 +22,7 @@ import (
|
|||||||
// temporary file is removed if any step fails.
|
// temporary file is removed if any step fails.
|
||||||
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
||||||
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
tmp, err := afero.TempFile(fs, filepath.Dir(path),
|
||||||
"."+filepath.Base(path)+".tmp-*")
|
"."+filepath.Base(path)+tempNamePart+"*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
return fmt.Errorf("failed to create temporary file for %s: %w", path, err)
|
||||||
}
|
}
|
||||||
@@ -52,7 +59,7 @@ func WriteFileAtomic(fs afero.Fs, path string, data []byte) error {
|
|||||||
// Its name leaves out target's, which may already be as long as a file name
|
// Its name leaves out target's, which may already be as long as a file name
|
||||||
// can be.
|
// can be.
|
||||||
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
func TempDirFor(fs afero.Fs, target string) (string, error) {
|
||||||
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), ".tmp-")
|
dir, err := afero.TempDir(fs, filepath.Dir(filepath.Dir(target)), tempNamePart)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"failed to create temporary directory for %s: %w", target, err)
|
"failed to create temporary directory for %s: %w", target, err)
|
||||||
@@ -61,6 +68,85 @@ func TempDirFor(fs afero.Fs, target string) (string, error) {
|
|||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RemoveLeftovers deletes from dir the temporary files of WriteFileAtomic
|
||||||
|
// and the temporary directories of TempDirFor that a command killed
|
||||||
|
// part-way left there: each entry whose name starts with "." and holds
|
||||||
|
// tempNamePart. The caller must hold the state directory lock, so that no
|
||||||
|
// running command is still using one. A dir that does not exist holds none.
|
||||||
|
func RemoveLeftovers(fs afero.Fs, dir string) error {
|
||||||
|
entries, err := afero.ReadDir(fs, dir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if !strings.HasPrefix(name, ".") || !strings.Contains(name, tempNamePart) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
|
||||||
|
err = fs.RemoveAll(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Removed what an interrupted command left", "path", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDir calls write to write the files of the new directory dir into a
|
||||||
|
// temporary directory from TempDirFor, which is then renamed to dir, so that
|
||||||
|
// neither a failure nor a crash leaves dir half-written; on a failure the
|
||||||
|
// temporary directory is removed, and a failure to remove it is returned
|
||||||
|
// along with the first. A directory cannot be replaced in one rename, so if
|
||||||
|
// dir already exists, WriteDir fails without calling write.
|
||||||
|
func WriteDir(fs afero.Fs, dir string, write func(dir string) error) error {
|
||||||
|
exists, err := afero.Exists(fs, dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return fmt.Errorf("failed to create %s: %w", dir, os.ErrExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the directory the finished one is renamed into
|
||||||
|
err = fs.MkdirAll(filepath.Dir(dir), DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create %s: %w", filepath.Dir(dir), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp, err := TempDirFor(fs, dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = write(tmp)
|
||||||
|
if err == nil {
|
||||||
|
err = fs.Rename(tmp, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
removeErr := fs.RemoveAll(tmp)
|
||||||
|
if removeErr != nil {
|
||||||
|
err = errors.Join(err,
|
||||||
|
fmt.Errorf("failed to remove %s: %w", tmp, removeErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
// RemoveDirAtomic deletes the directory dir so that it disappears in one
|
||||||
// rename: dir is moved into a new directory from TempDirFor, which is then
|
// rename: dir is moved into a new directory from TempDirFor, which is then
|
||||||
// deleted. A crash part-way leaves only that temporary directory behind.
|
// deleted. A crash part-way leaves only that temporary directory behind.
|
||||||
|
|||||||
+309
-40
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/macse"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -35,6 +36,10 @@ const currentFile = "current"
|
|||||||
// unlockerMetadataFile is the file a new unlocker writes last.
|
// unlockerMetadataFile is the file a new unlocker writes last.
|
||||||
const unlockerMetadataFile = "unlocker-metadata.json"
|
const unlockerMetadataFile = "unlocker-metadata.json"
|
||||||
|
|
||||||
|
// privKeyFile is the file that holds the encrypted private key of a version
|
||||||
|
// or of a passphrase unlocker.
|
||||||
|
const privKeyFile = "priv.age"
|
||||||
|
|
||||||
// unlockerPassphrase protects the passphrase unlockers the tests create.
|
// unlockerPassphrase protects the passphrase unlockers the tests create.
|
||||||
//
|
//
|
||||||
//nolint:gosec // G101: test data, not a real credential
|
//nolint:gosec // G101: test data, not a real credential
|
||||||
@@ -187,6 +192,22 @@ func dirNames(t *testing.T, fs afero.Fs, dir string) []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dirFiles returns the contents of the files in dir, by name.
|
||||||
|
func dirFiles(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
files := map[string]string{}
|
||||||
|
|
||||||
|
for _, name := range dirNames(t, fs, dir) {
|
||||||
|
data, err := afero.ReadFile(fs, filepath.Join(dir, name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
files[name] = string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
|
||||||
// writeLongTermKey gives the test vault under stateDir a new long-term key
|
// writeLongTermKey gives the test vault under stateDir a new long-term key
|
||||||
// and returns it.
|
// and returns it.
|
||||||
func writeLongTermKey(
|
func writeLongTermKey(
|
||||||
@@ -215,7 +236,7 @@ func newVaultWithSecret(
|
|||||||
) *vault.Vault {
|
) *vault.Vault {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, name)
|
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
buffer := memguard.NewBufferFromBytes([]byte(value))
|
buffer := memguard.NewBufferFromBytes([]byte(value))
|
||||||
@@ -325,14 +346,14 @@ func TestRemoveDirAtomic(t *testing.T) {
|
|||||||
// named with 255 bytes, the most a file name may have, on the real
|
// named with 255 bytes, the most a file name may have, on the real
|
||||||
// filesystem: the temporary directories they use must fit that limit too.
|
// filesystem: the temporary directories they use must fit that limit too.
|
||||||
func TestLongestNames(t *testing.T) {
|
func TestLongestNames(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
|
|
||||||
const longestName = 255
|
const longestName = 255
|
||||||
|
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
name := strings.Repeat("a", longestName)
|
name := strings.Repeat("a", longestName)
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, t.TempDir(), name)
|
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("long"))
|
value := memguard.NewBufferFromBytes([]byte("long"))
|
||||||
@@ -342,7 +363,10 @@ func TestLongestNames(t *testing.T) {
|
|||||||
|
|
||||||
got, err := vlt.GetSecret(name)
|
got, err := vlt.GetSecret(name)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "long", string(got))
|
|
||||||
|
defer got.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("long"), got.Bytes())
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
vaultDir, err := vlt.GetDirectory()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -354,13 +378,13 @@ func TestLongestNames(t *testing.T) {
|
|||||||
// another vault, as a forced move between vaults does, and makes the last
|
// another vault, as a forced move between vaults does, and makes the last
|
||||||
// step that completes the copy fail. The secret it was to replace must
|
// step that completes the copy fail. The secret it was to replace must
|
||||||
// still be there unchanged: it may go only once its replacement is whole.
|
// still be there unchanged: it may go only once its replacement is whole.
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv forbids t.Parallel
|
|
||||||
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
for _, tfs := range testFilesystems {
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
src := newVaultWithSecret(t, base, stateDir, "source", "new")
|
src := newVaultWithSecret(t, base, stateDir, "source", "new")
|
||||||
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
|
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
|
||||||
@@ -380,7 +404,10 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
|||||||
|
|
||||||
value, err := dest.GetSecret("shared")
|
value, err := dest.GetSecret("shared")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "old", string(value))
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("old"), value.Bytes())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -390,13 +417,13 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
|
|||||||
// directory directly in secrets.d or in a versions directory. Those are
|
// directory directly in secrets.d or in a versions directory. Those are
|
||||||
// listed to find secrets and versions, so a temporary directory made there
|
// listed to find secrets and versions, so a temporary directory made there
|
||||||
// would be listed while half-built, and one left by a crash would stay.
|
// would be listed while half-built, and one left by a crash would stay.
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv forbids t.Parallel
|
|
||||||
func TestTempDirsStayOutOfListings(t *testing.T) {
|
func TestTempDirsStayOutOfListings(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
for _, tfs := range testFilesystems {
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
newVaultWithSecret(t, base, stateDir, "default", "first")
|
newVaultWithSecret(t, base, stateDir, "default", "first")
|
||||||
|
|
||||||
@@ -409,6 +436,7 @@ func TestTempDirsStayOutOfListings(t *testing.T) {
|
|||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
vlt := vault.NewVault(fs, stateDir, "default")
|
vlt := vault.NewVault(fs, stateDir, "default")
|
||||||
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes([]byte("second"))
|
value := memguard.NewBufferFromBytes([]byte("second"))
|
||||||
defer value.Destroy()
|
defer value.Destroy()
|
||||||
@@ -447,7 +475,7 @@ func TestVersionSaveIsWholeOrAbsent(t *testing.T) {
|
|||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
assert.ElementsMatch(t,
|
assert.ElementsMatch(t,
|
||||||
[]string{"pub.age", "value.age", "priv.age", "metadata.age"},
|
[]string{"pub.age", "value.age", privKeyFile, "metadata.age"},
|
||||||
dirNames(t, base, versionDir),
|
dirNames(t, base, versionDir),
|
||||||
"version directory visible before it was complete")
|
"version directory visible before it was complete")
|
||||||
}
|
}
|
||||||
@@ -490,7 +518,7 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
|
|||||||
writeLongTermKey(t, base, stateDir)
|
writeLongTermKey(t, base, stateDir)
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(op, path string) error {
|
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||||
if op == opRename && filepath.Base(path) == "priv.age" {
|
if op == opRename && filepath.Base(path) == privKeyFile {
|
||||||
return errInjected
|
return errInjected
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -517,13 +545,13 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
|
|||||||
// unlocker again and checks, before each change this makes, that the file
|
// unlocker again and checks, before each change this makes, that the file
|
||||||
// naming the current one exists: a reader or a crash never finds it
|
// naming the current one exists: a reader or a crash never finds it
|
||||||
// missing.
|
// missing.
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv forbids t.Parallel
|
|
||||||
func TestCurrentFilesNeverMissing(t *testing.T) {
|
func TestCurrentFilesNeverMissing(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
|
|
||||||
for _, tfs := range testFilesystems {
|
for _, tfs := range testFilesystems {
|
||||||
t.Run(tfs.name, func(t *testing.T) {
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
base, stateDir := tfs.open(t)
|
base, stateDir := tfs.open(t)
|
||||||
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
|
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
|
||||||
|
|
||||||
@@ -615,11 +643,11 @@ func TestWriteFileAtomicTempFile(t *testing.T) {
|
|||||||
// anything, so that it never leaves a partial unlocker, nor breaks the one
|
// anything, so that it never leaves a partial unlocker, nor breaks the one
|
||||||
// it would replace.
|
// it would replace.
|
||||||
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
||||||
// No mnemonic, and no current unlocker to get the key from
|
t.Parallel()
|
||||||
t.Setenv(secret.EnvMnemonic, "")
|
|
||||||
|
|
||||||
|
// No mnemonic, and no current unlocker to get the key from
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||||
@@ -636,37 +664,278 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
|
|||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestPassphraseUnlockerWritesMetadataLast checks that the last file a new
|
// TestPassphraseUnlockerIsWholeOrAbsent checks, before every change that
|
||||||
// passphrase unlocker writes in its directory is its metadata: an unlocker
|
// creating a passphrase unlocker makes, that the unlocker's directory either
|
||||||
// directory without metadata is never used, so one interrupted earlier
|
// does not exist or holds all of its files: a crash or a failure at any point
|
||||||
// cannot be.
|
// leaves no partial unlocker.
|
||||||
func TestPassphraseUnlockerWritesMetadataLast(t *testing.T) {
|
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
|
|
||||||
|
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
|
||||||
|
|
||||||
|
for _, tfs := range testFilesystems {
|
||||||
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base, stateDir := tfs.open(t)
|
||||||
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The vault has no unlocker yet, so any directory in here is
|
||||||
|
// the new one
|
||||||
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
|
fs := hookFs{Fs: base, before: func(string, string) error {
|
||||||
|
for _, name := range dirNames(t, base, unlockersDir) {
|
||||||
|
assert.ElementsMatch(t, files,
|
||||||
|
dirNames(t, base, filepath.Join(unlockersDir, name)),
|
||||||
|
"unlocker directory visible before it was complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
hooked := vault.NewVault(fs, stateDir, testVaultName)
|
||||||
|
hooked.Mnemonic = vlt.Mnemonic
|
||||||
|
|
||||||
|
unlocker, err := hooked.CreatePassphraseUnlocker(passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, files, dirNames(t, base, unlocker.GetDirectory()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPassphraseUnlockerReplacementKeepsVaultOpen replaces the vault's
|
||||||
|
// passphrase unlocker twice, each time with only the current unlocker to open
|
||||||
|
// the vault. The first replacement fails right after making the new unlocker
|
||||||
|
// current, so the old one is not removed. The second checks, before every
|
||||||
|
// change it makes, that the vault opens with the passphrase through its
|
||||||
|
// current unlocker, which is what a crash at that change would leave; once it
|
||||||
|
// returns, the vault must have one passphrase unlocker left.
|
||||||
|
func TestPassphraseUnlockerReplacementKeepsVaultOpen(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tfs := range testFilesystems {
|
||||||
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base, stateDir := tfs.open(t)
|
||||||
|
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ltIdentity, err := vlt.GetOrDeriveLongTermKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
_, err = vlt.CreatePassphraseUnlocker(passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
|
// Every change after the switch to the new unlocker fails
|
||||||
|
switched := false
|
||||||
|
failAfterSwitch := hookFs{Fs: base, before: func(op, path string) error {
|
||||||
|
if switched {
|
||||||
|
return errInjected
|
||||||
|
}
|
||||||
|
|
||||||
|
switched = op == opRename && path == currentUnlockerPath
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
replacing := vault.NewVault(failAfterSwitch, stateDir, testVaultName)
|
||||||
|
replacing.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
||||||
|
require.ErrorIs(t, err, errInjected)
|
||||||
|
|
||||||
|
unlockers, err := vlt.ListUnlockers()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, unlockers, 2, "the old unlocker is left beside the new")
|
||||||
|
|
||||||
|
assertOpens := vaultOpensCheck(t, base, stateDir, ltIdentity, passphrase)
|
||||||
|
checked := hookFs{Fs: base, before: func(string, string) error {
|
||||||
|
assertOpens()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
replacing = vault.NewVault(checked, stateDir, testVaultName)
|
||||||
|
replacing.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
_, err = replacing.CreatePassphraseUnlocker(passphrase)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assertOpens()
|
||||||
|
|
||||||
|
unlockers, err = vlt.ListUnlockers()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, unlockers, 1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// vaultOpensCheck returns a function that checks that the test vault under
|
||||||
|
// stateDir opens through its current unlocker, with passphrase, to the
|
||||||
|
// long-term key ltIdentity. Opening it takes a second, so an unlocker
|
||||||
|
// directory it has opened through before is not opened again: it must hold
|
||||||
|
// the same files as then.
|
||||||
|
func vaultOpensCheck(
|
||||||
|
t *testing.T, fs afero.Fs, stateDir string, ltIdentity *age.X25519Identity,
|
||||||
|
passphrase *memguard.LockedBuffer,
|
||||||
|
) func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
vaultDir := filepath.Join(stateDir, "vaults.d", testVaultName)
|
||||||
|
|
||||||
|
// The files of each unlocker directory the vault has opened through
|
||||||
|
opened := map[string]map[string]string{}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
current, err := afero.ReadFile(fs, filepath.Join(vaultDir, "current-unlocker"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
files := dirFiles(t, fs, filepath.Join(vaultDir, "unlockers.d", string(current)))
|
||||||
|
|
||||||
|
if before, ok := opened[string(current)]; ok {
|
||||||
|
assert.Equal(t, before, files, "unlocker changed since it opened the vault")
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opener := vault.NewVault(fs, stateDir, testVaultName)
|
||||||
|
opener.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
key, err := opener.UnlockVault()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, ltIdentity.Recipient().String(), key.Recipient().String())
|
||||||
|
|
||||||
|
opened[string(current)] = files
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteDirFailureLeavesNothing makes writing a new directory fail after
|
||||||
|
// a file has been written in it, and checks that neither the directory nor
|
||||||
|
// its temporary directory is left behind; and, when the temporary directory
|
||||||
|
// cannot be removed either, that both failures are reported.
|
||||||
|
func TestWriteDirFailureLeavesNothing(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tfs := range testFilesystems {
|
||||||
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base, dir := tfs.open(t)
|
||||||
|
listed := filepath.Join(dir, "unlockers.d")
|
||||||
|
target := filepath.Join(listed, "new")
|
||||||
|
|
||||||
|
writeThenFail := func(tmp string) error {
|
||||||
|
require.NoError(t, secret.WriteFileAtomic(base,
|
||||||
|
filepath.Join(tmp, unlockerMetadataFile), []byte("{}")))
|
||||||
|
|
||||||
|
return errInjected
|
||||||
|
}
|
||||||
|
|
||||||
|
err := secret.WriteDir(base, target, writeThenFail)
|
||||||
|
require.ErrorIs(t, err, errInjected)
|
||||||
|
|
||||||
|
// Nothing in the directory that is listed, nor beside it
|
||||||
|
assert.Empty(t, dirNames(t, base, listed))
|
||||||
|
assert.Equal(t, []string{"unlockers.d"}, dirNames(t, base, dir))
|
||||||
|
|
||||||
|
fs := hookFs{Fs: base, before: func(op, _ string) error {
|
||||||
|
if op == opRemove {
|
||||||
|
return os.ErrPermission
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
err = secret.WriteDir(fs, target, writeThenFail)
|
||||||
|
require.ErrorIs(t, err, errInjected)
|
||||||
|
require.ErrorIs(t, err, os.ErrPermission)
|
||||||
|
assert.Empty(t, dirNames(t, base, listed))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteDirRefusesExistingDir checks that WriteDir fails, without calling
|
||||||
|
// write, when the directory already exists, and leaves the directory as it
|
||||||
|
// was: it never writes into a directory in place.
|
||||||
|
func TestWriteDirRefusesExistingDir(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, tfs := range testFilesystems {
|
||||||
|
t.Run(tfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs, dir := tfs.open(t)
|
||||||
|
target := filepath.Join(dir, "unlockers.d", "existing")
|
||||||
|
require.NoError(t, fs.MkdirAll(target, secret.DirPerms))
|
||||||
|
require.NoError(t, secret.WriteFileAtomic(fs,
|
||||||
|
filepath.Join(target, unlockerMetadataFile), []byte("{}")))
|
||||||
|
|
||||||
|
err := secret.WriteDir(fs, target, func(string) error {
|
||||||
|
t.Error("write called for a directory that exists")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
require.ErrorIs(t, err, os.ErrExist)
|
||||||
|
assert.Equal(t, []string{unlockerMetadataFile}, dirNames(t, fs, target))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecureEnclaveUnlockerFailureDeletesKey makes moving a new Secure
|
||||||
|
// Enclave unlocker into place fail after its Secure Enclave key is created:
|
||||||
|
// the key must be deleted again. Skipped when the add fails before that, as
|
||||||
|
// it does everywhere but in a macOS build with cgo on a Mac with a Secure
|
||||||
|
// Enclave.
|
||||||
|
func TestSecureEnclaveUnlockerFailureDeletesKey(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
base := afero.NewMemMapFs()
|
base := afero.NewMemMapFs()
|
||||||
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
|
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
// The unlocker's directory is named se-<label of its Secure Enclave key>
|
||||||
require.NoError(t, err)
|
var seKeyLabel string
|
||||||
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", "passphrase")
|
fs := hookFs{Fs: base, before: func(op, path string) error {
|
||||||
|
if op == opRename && filepath.Base(filepath.Dir(path)) == "unlockers.d" {
|
||||||
|
seKeyLabel = strings.TrimPrefix(filepath.Base(path), "se-")
|
||||||
|
|
||||||
var last string
|
return errInjected
|
||||||
|
|
||||||
fs := hookFs{Fs: base, before: func(_, path string) error {
|
|
||||||
if filepath.Dir(path) == unlockerDir {
|
|
||||||
last = filepath.Base(path)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
|
|
||||||
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
|
_, err = secret.CreateSecureEnclaveUnlocker(fs, testVaultStateDir, mnemonic,
|
||||||
defer passphrase.Destroy()
|
nil)
|
||||||
|
|
||||||
_, err = vault.NewVault(fs, testVaultStateDir, testVaultName).
|
if seKeyLabel == "" {
|
||||||
CreatePassphraseUnlocker(passphrase)
|
t.Skipf("the add failed before moving the unlocker into place: %v", err)
|
||||||
require.NoError(t, err)
|
}
|
||||||
assert.Equal(t, unlockerMetadataFile, last)
|
|
||||||
|
require.ErrorIs(t, err, errInjected)
|
||||||
|
|
||||||
|
_, err = macse.Encrypt(seKeyLabel, []byte("test"))
|
||||||
|
assert.Error(t, err, "Secure Enclave key left behind")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const (
|
|||||||
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
EnvUnlockPassphrase = "SB_UNLOCK_PASSPHRASE"
|
||||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||||
|
|
||||||
|
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
||||||
|
// new unlocker's directory, keychain item and Secure Enclave key. It runs
|
||||||
|
// to the nanosecond, so that every new unlocker, even one added right
|
||||||
|
// after another, gets a directory of its own.
|
||||||
|
UnlockerTimeFormat = "2006-01-02.15.04.05.000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
// File system permission constants
|
// File system permission constants
|
||||||
|
|||||||
+57
-19
@@ -7,6 +7,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -16,16 +17,17 @@ import (
|
|||||||
var (
|
var (
|
||||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||||
errStdinNotTerminal = errors.New(
|
errStdinNotTerminal = errors.New(
|
||||||
"cannot read passphrase from non-terminal stdin " +
|
"stdin is not a terminal (piped input or script)")
|
||||||
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
|
|
||||||
"environment variable or run interactively")
|
|
||||||
errStderrNotTerminal = errors.New(
|
errStderrNotTerminal = errors.New(
|
||||||
"cannot prompt for passphrase: stderr is not a terminal " +
|
"stderr is not a terminal (running in non-interactive mode)")
|
||||||
"(running in non-interactive mode). Please set the " +
|
errNothingEntered = errors.New("nothing was entered")
|
||||||
"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(
|
||||||
@@ -102,6 +104,23 @@ func DecryptWithIdentity(
|
|||||||
return resultBuffer, nil
|
return resultBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityToLockedBuffer returns the private key of id, in age's text form, in
|
||||||
|
// a new locked buffer. The caller must destroy it.
|
||||||
|
//
|
||||||
|
// This is best effort. age gives the key only as a string in ordinary memory.
|
||||||
|
// The bytes of that string are moved into the buffer, which overwrites them,
|
||||||
|
// although Go otherwise never changes a string; nothing else holds this one.
|
||||||
|
// The copies age makes while building the string are left in ordinary memory.
|
||||||
|
// Avoiding those would mean encoding the key here, straight into the buffer.
|
||||||
|
func IdentityToLockedBuffer(id *age.X25519Identity) *memguard.LockedBuffer {
|
||||||
|
key := id.String()
|
||||||
|
|
||||||
|
//nolint:gosec // G103: the string's own bytes, which NewBufferFromBytes wipes
|
||||||
|
keyBytes := unsafe.Slice(unsafe.StringData(key), len(key))
|
||||||
|
|
||||||
|
return memguard.NewBufferFromBytes(keyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
// EncryptWithPassphrase encrypts data using a passphrase with age's
|
||||||
// scrypt-based encryption. Both data and passphrase parameters should
|
// scrypt-based encryption. Both data and passphrase parameters should
|
||||||
// be LockedBuffers for secure memory handling
|
// be LockedBuffers for secure memory handling
|
||||||
@@ -148,42 +167,61 @@ 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 passphrases from piped input
|
// Not a terminal - never read secrets from piped input
|
||||||
// for security reasons
|
// for security reasons
|
||||||
return nil, errStdinNotTerminal
|
return nil, fmt.Errorf(
|
||||||
|
"%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, errStderrNotTerminal
|
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
|
||||||
|
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
|
||||||
|
|
||||||
passphrase, err := term.ReadPassword(syscall.Stdin)
|
input, err := term.ReadPassword(syscall.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
return nil, fmt.Errorf("%w: %w", notRead, 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(passphrase) == 0 {
|
if len(input) == 0 {
|
||||||
return nil, errEmptyPassphrase
|
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a secure buffer and copy the passphrase
|
// Create a secure buffer and copy the input
|
||||||
secureBuffer := memguard.NewBufferFromBytes(passphrase)
|
secureBuffer := memguard.NewBufferFromBytes(input)
|
||||||
|
|
||||||
// Clear the original passphrase slice
|
// Clear the original input slice
|
||||||
for i := range passphrase {
|
for i := range input {
|
||||||
passphrase[i] = 0
|
input[i] = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return secureBuffer, nil
|
return secureBuffer, nil
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package secret_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIdentityToLockedBuffer checks that the buffer holds the identity's
|
||||||
|
// private key, and that the identity still gives that key afterwards: the
|
||||||
|
// helper overwrites the string age returned, so age must not keep it.
|
||||||
|
func TestIdentityToLockedBuffer(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
identity, err := age.GenerateX25519Identity()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
buffer := secret.IdentityToLockedBuffer(identity)
|
||||||
|
defer buffer.Destroy()
|
||||||
|
|
||||||
|
parsed, err := age.ParseX25519Identity(buffer.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, identity.Recipient().String(), parsed.Recipient().String())
|
||||||
|
|
||||||
|
assert.Equal(t, identity.String(), buffer.String())
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported getLongTermPrivateKey
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -26,19 +28,44 @@ type realVault struct {
|
|||||||
func (v *realVault) GetDirectory() (string, error) {
|
func (v *realVault) GetDirectory() (string, error) {
|
||||||
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
return filepath.Join(v.stateDir, "vaults.d", v.name), nil
|
||||||
}
|
}
|
||||||
func (v *realVault) GetName() string { return v.name }
|
func (v *realVault) GetName() string { return v.name }
|
||||||
|
|
||||||
|
//nolint:ireturn // implements VaultInterface
|
||||||
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
||||||
|
|
||||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error {
|
||||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
panic("not used")
|
||||||
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
}
|
||||||
|
|
||||||
|
//nolint:ireturn // implements VaultInterface
|
||||||
|
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) {
|
||||||
|
panic("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
|
panic("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *realVault) SetMnemonic(*memguard.LockedBuffer) {
|
||||||
|
panic("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) {
|
||||||
|
panic("not used")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *realVault) CreatePassphraseUnlocker(
|
||||||
|
*memguard.LockedBuffer,
|
||||||
|
) (*PassphraseUnlocker, error) {
|
||||||
panic("not used")
|
panic("not used")
|
||||||
}
|
}
|
||||||
|
|
||||||
// createRealVault sets up a complete vault directory structure on an in-memory
|
// createRealVault sets up a complete vault directory structure on an in-memory
|
||||||
// filesystem, identical to what vault.CreateVault produces.
|
// filesystem, identical to what vault.CreateVault produces.
|
||||||
func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32) *realVault {
|
func createRealVault(
|
||||||
|
t *testing.T, fs afero.Fs, stateDir, name string, derivationIndex uint32,
|
||||||
|
) *realVault {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
@@ -51,13 +78,18 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
|
|||||||
}
|
}
|
||||||
metaBytes, err := json.Marshal(metadata)
|
metaBytes, err := json.Marshal(metadata)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
require.NoError(t, afero.WriteFile(fs,
|
||||||
|
filepath.Join(vaultDir, "vault-metadata.json"), metaBytes, FilePerms))
|
||||||
|
|
||||||
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
return &realVault{name: name, stateDir: stateDir, fs: fs}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
t.Parallel()
|
||||||
|
|
||||||
|
//nolint:dupword // BIP39 test mnemonic repeats words by design
|
||||||
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// Derive expected keys at two different indices to prove they differ.
|
// Derive expected keys at two different indices to prove they differ.
|
||||||
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
key0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
@@ -71,10 +103,12 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
|
|||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
vault := createRealVault(t, fs, "/state", "test-vault", 5)
|
||||||
|
|
||||||
t.Setenv(EnvMnemonic, testMnemonic)
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
result, err := getLongTermPrivateKey(fs, vault)
|
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
defer result.Destroy()
|
defer result.Destroy()
|
||||||
|
|
||||||
assert.Equal(t, key5.String(), string(result.Bytes()),
|
assert.Equal(t, key5.String(), string(result.Bytes()),
|
||||||
|
|||||||
+261
-267
@@ -1,39 +1,53 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
keychain "github.com/keybase/go-keychain"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
agePrivKeyPassphraseLength = 64
|
agePrivKeyPassphraseLength = 64
|
||||||
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
// KEYCHAIN_APP_IDENTIFIER is the service name used for keychain items
|
||||||
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret" //nolint:revive // ALL_CAPS is intentional for this constant
|
//
|
||||||
|
//nolint:revive // ALL_CAPS is intentional for this constant
|
||||||
|
KEYCHAIN_APP_IDENTIFIER = "berlin.sneak.app.secret"
|
||||||
|
|
||||||
|
// keychainUnlockerType is the metadata type string for keychain unlockers.
|
||||||
|
keychainUnlockerType = "keychain"
|
||||||
|
|
||||||
|
// macOSFlag is the unlocker metadata flag of the macOS-only unlockers.
|
||||||
|
macOSFlag = "macos"
|
||||||
)
|
)
|
||||||
|
|
||||||
// keychainItemNameRegex validates keychain item names
|
// keychainItemNameRegex validates keychain item names
|
||||||
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
// Allows alphanumeric characters, dots, hyphens, and underscores only
|
||||||
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
var keychainItemNameRegex = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errKeychainItemNameEmpty = errors.New("keychain item name cannot be empty")
|
||||||
|
errInvalidKeychainItemName = errors.New("invalid keychain item name format")
|
||||||
|
errUnsupportedCurrentUnlocker = errors.New(
|
||||||
|
"unsupported current unlocker type for keychain unlocker creation")
|
||||||
|
)
|
||||||
|
|
||||||
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
// KeychainUnlockerMetadata extends UnlockerMetadata with keychain-specific data
|
||||||
type KeychainUnlockerMetadata struct {
|
type KeychainUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
// Keychain item name
|
// Keychain item name
|
||||||
KeychainItemName string `json:"keychainItemName"`
|
KeychainItemName string `json:"keychainItemName"`
|
||||||
}
|
}
|
||||||
@@ -45,6 +59,17 @@ type KeychainUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
||||||
|
func NewKeychainUnlocker(
|
||||||
|
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
||||||
|
) *KeychainUnlocker {
|
||||||
|
return &KeychainUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
// GetIdentity implements Unlocker interface for Keychain-based unlockers
|
||||||
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
DebugWith("Getting keychain unlocker identity",
|
DebugWith("Getting keychain unlocker identity",
|
||||||
@@ -52,50 +77,20 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
slog.String("unlocker_type", k.GetType()),
|
slog.String("unlocker_type", k.GetType()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Step 1: Get keychain item name
|
keychainData, err := k.readKeychainData()
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
return nil, err
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: Retrieve data from keychain
|
|
||||||
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
|
||||||
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to retrieve data from keychain", "error", err, "keychain_item", keychainItemName)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugWith("Retrieved data from keychain",
|
|
||||||
slog.String("unlocker_id", k.GetID()),
|
|
||||||
slog.Int("data_length", len(keychainDataBytes)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Move the keychain data into locked memory; this wipes keychainDataBytes
|
|
||||||
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
|
||||||
defer keychainDataBuffer.Destroy()
|
|
||||||
|
|
||||||
// Step 3: Parse keychain data
|
|
||||||
keychainData, err := decodeKeychainData(keychainDataBuffer)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
|
||||||
}
|
}
|
||||||
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
defer keychainData.AgePrivKeyPassphrase.Destroy()
|
||||||
|
|
||||||
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
// Step 4: Read the encrypted age private key from filesystem
|
// Step 4: Read the encrypted age private key from filesystem
|
||||||
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
agePrivKeyPath := filepath.Join(k.Directory, "priv.age")
|
||||||
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
|
Debug("Reading encrypted age private key", "path", agePrivKeyPath)
|
||||||
|
|
||||||
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
encryptedAgePrivKeyData, err := afero.ReadFile(k.fs, agePrivKeyPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read encrypted age private key", "error", err, "path", agePrivKeyPath)
|
Debug("Failed to read encrypted age private key",
|
||||||
|
"error", err, "path", agePrivKeyPath)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -106,12 +101,17 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Step 5: Decrypt the age private key using the passphrase from keychain
|
// Step 5: Decrypt the age private key using the passphrase from keychain
|
||||||
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
|
Debug("Decrypting age private key with keychain passphrase",
|
||||||
agePrivKeyBuffer, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
"unlocker_id", k.GetID())
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt age private key with keychain passphrase: %w", err)
|
agePrivKeyBuffer, err := DecryptWithPassphrase(
|
||||||
|
encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to decrypt age private key with keychain passphrase",
|
||||||
|
"error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to decrypt age private key with keychain passphrase: %w", err)
|
||||||
}
|
}
|
||||||
defer agePrivKeyBuffer.Destroy()
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// GetType implements Unlocker interface
|
// GetType implements Unlocker interface
|
||||||
func (k *KeychainUnlocker) GetType() string {
|
func (k *KeychainUnlocker) GetType() string {
|
||||||
return "keychain"
|
return keychainUnlockerType
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMetadata implements Unlocker interface
|
// GetMetadata implements Unlocker interface
|
||||||
@@ -153,20 +153,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
return k.Directory
|
return k.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from keychain item name
|
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
// Generate ID in the format YYYY-MM-DD.HH.mm-hostname-keychain
|
return filepath.Base(k.Directory)
|
||||||
// This matches the passphrase unlocker format
|
|
||||||
hostname, err := os.Hostname()
|
|
||||||
if err != nil {
|
|
||||||
hostname = "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the creation timestamp from metadata
|
|
||||||
createdAt := k.Metadata.CreatedAt
|
|
||||||
timestamp := createdAt.Format("2006-01-02.15.04")
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s-keychain", timestamp, hostname)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the keychain unlocker
|
// Remove implements Unlocker interface - removes the keychain unlocker
|
||||||
@@ -174,58 +163,105 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
// Step 1: Get keychain item name
|
// Step 1: Get keychain item name
|
||||||
keychainItemName, err := k.GetKeychainItemName()
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to get keychain item name during removal", "error", err, "unlocker_id", k.GetID())
|
Debug("Failed to get keychain item name during removal",
|
||||||
|
"error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
return fmt.Errorf("failed to get keychain item name: %w", err)
|
return fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Remove from keychain
|
// Step 2: Remove from keychain
|
||||||
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
Debug("Removing keychain item", "keychain_item", keychainItemName)
|
||||||
if err := deleteFromKeychain(keychainItemName); err != nil {
|
|
||||||
Debug("Failed to remove keychain item", "error", err, "keychain_item", keychainItemName)
|
err = deleteFromKeychain(keychainItemName)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to remove keychain item",
|
||||||
|
"error", err, "keychain_item", keychainItemName)
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain item: %w", err)
|
return fmt.Errorf("failed to remove keychain item: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Remove directory
|
// Step 3: Remove directory
|
||||||
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
Debug("Removing keychain unlocker directory", "directory", k.Directory)
|
||||||
if err := RemoveDirAtomic(k.fs, k.Directory); err != nil {
|
|
||||||
Debug("Failed to remove keychain unlocker directory", "error", err, "directory", k.Directory)
|
err = RemoveDirAtomic(k.fs, k.Directory)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to remove keychain unlocker directory",
|
||||||
|
"error", err, "directory", k.Directory)
|
||||||
|
|
||||||
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove keychain unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("Successfully removed keychain unlocker", "unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
Debug("Successfully removed keychain unlocker",
|
||||||
|
"unlocker_id", k.GetID(), "keychain_item", keychainItemName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewKeychainUnlocker creates a new KeychainUnlocker instance
|
|
||||||
func NewKeychainUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *KeychainUnlocker {
|
|
||||||
return &KeychainUnlocker{
|
|
||||||
Directory: directory,
|
|
||||||
Metadata: metadata,
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetKeychainItemName returns the keychain item name from metadata
|
// GetKeychainItemName returns the keychain item name from metadata
|
||||||
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
func (k *KeychainUnlocker) GetKeychainItemName() (string, error) {
|
||||||
// Load the metadata
|
// Load the metadata
|
||||||
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(k.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
metadataData, err := afero.ReadFile(k.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
return "", fmt.Errorf("failed to read keychain metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var keychainMetadata KeychainUnlockerMetadata
|
var keychainMetadata KeychainUnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataData, &keychainMetadata); err != nil {
|
|
||||||
|
err = json.Unmarshal(metadataData, &keychainMetadata)
|
||||||
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
return "", fmt.Errorf("failed to parse keychain metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return keychainMetadata.KeychainItemName, nil
|
return keychainMetadata.KeychainItemName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readKeychainData reads and parses the data this unlocker keeps in the
|
||||||
|
// keychain (steps 1 to 3 of GetIdentity). The caller must destroy the
|
||||||
|
// returned AgePrivKeyPassphrase.
|
||||||
|
func (k *KeychainUnlocker) readKeychainData() (*KeychainData, error) {
|
||||||
|
// Step 1: Get keychain item name
|
||||||
|
keychainItemName, err := k.GetKeychainItemName()
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to get keychain item name", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to get keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Retrieve data from keychain
|
||||||
|
Debug("Retrieving data from macOS keychain", "keychain_item", keychainItemName)
|
||||||
|
|
||||||
|
keychainDataBytes, err := retrieveFromKeychain(keychainItemName)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to retrieve data from keychain",
|
||||||
|
"error", err, "keychain_item", keychainItemName)
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to retrieve data from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugWith("Retrieved data from keychain",
|
||||||
|
slog.String("unlocker_id", k.GetID()),
|
||||||
|
slog.Int("data_length", len(keychainDataBytes)),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Move the keychain data into locked memory; this wipes keychainDataBytes
|
||||||
|
keychainDataBuffer := memguard.NewBufferFromBytes(keychainDataBytes)
|
||||||
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
|
// Step 3: Parse keychain data
|
||||||
|
keychainData, err := decodeKeychainData(keychainDataBuffer)
|
||||||
|
if err != nil {
|
||||||
|
Debug("Failed to parse keychain data", "error", err, "unlocker_id", k.GetID())
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to parse keychain data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Parsed keychain data successfully", "unlocker_id", k.GetID())
|
||||||
|
|
||||||
|
return keychainData, nil
|
||||||
|
}
|
||||||
|
|
||||||
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
// generateKeychainUnlockerName generates a unique name for the keychain unlocker
|
||||||
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
func generateKeychainUnlockerName(vaultName string) (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -233,43 +269,21 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: secret-<vault>-<hostname>-<date>
|
// Format: secret-<vault>-<hostname>-<time>
|
||||||
enrollmentDate := time.Now().Format("2006-01-02")
|
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||||
|
|
||||||
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
|
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentTime), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
|
// getLongTermPrivateKey derives the long-term private key from mnemonic when
|
||||||
|
// it is not nil, else gets it through the current unlocker, which is given
|
||||||
|
// passphrase when it is a passphrase unlocker.
|
||||||
// Returns a LockedBuffer to ensure the private key is protected in memory
|
// Returns a LockedBuffer to ensure the private key is protected in memory
|
||||||
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
|
func getLongTermPrivateKey(
|
||||||
// Check if mnemonic is available in environment variable
|
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
envMnemonic := os.Getenv(EnvMnemonic)
|
) (*memguard.LockedBuffer, error) {
|
||||||
if envMnemonic != "" {
|
if mnemonic != nil {
|
||||||
// Read vault metadata to get the correct derivation index
|
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata VaultMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use mnemonic with the vault's actual derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the private key in a secure buffer
|
|
||||||
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the vault to access current unlocker
|
// Get the vault to access current unlocker
|
||||||
@@ -278,6 +292,10 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
||||||
|
passphraseUnlocker.Passphrase = passphrase
|
||||||
|
}
|
||||||
|
|
||||||
// Get the current unlocker identity
|
// Get the current unlocker identity
|
||||||
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -286,34 +304,43 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
|
|||||||
|
|
||||||
// Get encrypted long-term key from current unlocker, handling different types
|
// Get encrypted long-term key from current unlocker, handling different types
|
||||||
var encryptedLtPrivKey []byte
|
var encryptedLtPrivKey []byte
|
||||||
|
|
||||||
switch currentUnlocker := currentUnlocker.(type) {
|
switch currentUnlocker := currentUnlocker.(type) {
|
||||||
case *PassphraseUnlocker:
|
case *PassphraseUnlocker:
|
||||||
// Read the encrypted long-term private key from passphrase unlocker
|
// Read the encrypted long-term private key from passphrase unlocker
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||||
|
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||||
|
"from current passphrase unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
case *PGPUnlocker:
|
case *PGPUnlocker:
|
||||||
// Read the encrypted long-term private key from PGP unlocker
|
// Read the encrypted long-term private key from PGP unlocker
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||||
|
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||||
|
"from current PGP unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
case *KeychainUnlocker:
|
case *KeychainUnlocker:
|
||||||
// Read the encrypted long-term private key from another keychain unlocker
|
// Read the encrypted long-term private key from another keychain
|
||||||
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
// unlocker
|
||||||
|
encryptedLtPrivKey, err = afero.ReadFile(fs,
|
||||||
|
filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to read encrypted long-term key from current keychain unlocker: %w", err)
|
return nil, fmt.Errorf("failed to read encrypted long-term key "+
|
||||||
|
"from current keychain unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported current unlocker type for keychain unlocker creation")
|
return nil, errUnsupportedCurrentUnlocker
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt long-term private key using current unlocker
|
// Decrypt long-term private key using current unlocker
|
||||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
|
ltPrivKeyBuffer, err := DecryptWithIdentity(
|
||||||
|
encryptedLtPrivKey, currentUnlockerIdentity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -322,13 +349,48 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
|
|||||||
return ltPrivKeyBuffer, nil
|
return ltPrivKeyBuffer, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
|
// deriveLongTermPrivateKey derives the long-term private key from mnemonic at
|
||||||
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
|
// the vault's derivation index, for getLongTermPrivateKey and
|
||||||
// Check if we're on macOS
|
// getLongTermKeyForSE.
|
||||||
if err := checkMacOSAvailable(); err != nil {
|
func deriveLongTermPrivateKey(
|
||||||
return nil, err
|
fs afero.Fs, vault VaultInterface, mnemonic *memguard.LockedBuffer,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
|
// Read vault metadata to get the correct derivation index
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata VaultMetadata
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use mnemonic with the vault's actual derivation index
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"failed to derive long-term key from mnemonic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return IdentityToLockedBuffer(ltIdentity), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
|
||||||
|
// vault. The long-term key comes from mnemonic when it is not nil, else from
|
||||||
|
// the current unlocker, as getLongTermPrivateKey describes.
|
||||||
|
func CreateKeychainUnlocker(
|
||||||
|
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
|
) (*KeychainUnlocker, error) {
|
||||||
// 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 {
|
||||||
@@ -341,16 +403,13 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
return nil, fmt.Errorf("failed to generate keychain item name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create unlocker directory using the keychain item name as the directory name
|
// The unlocker directory is named after the keychain item
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", keychainItemName)
|
||||||
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the keychain unlocker
|
// Step 1: Generate a new age keypair for the keychain unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
@@ -358,6 +417,8 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ageRecipient := ageIdentity.Recipient().String()
|
||||||
|
|
||||||
// Step 2: Generate a random passphrase for encrypting the age private key
|
// Step 2: Generate a random passphrase for encrypting the age private key
|
||||||
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
agePrivKeyPassphrase, err := generateRandomPassphrase(agePrivKeyPassphraseLength)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -365,49 +426,32 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
}
|
}
|
||||||
defer agePrivKeyPassphrase.Destroy()
|
defer agePrivKeyPassphrase.Destroy()
|
||||||
|
|
||||||
// Step 3: Store age recipient as plaintext
|
// Step 3: Encrypt age private key with the generated passphrase
|
||||||
ageRecipient := ageIdentity.Recipient().String()
|
agePrivKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
||||||
if err := WriteFileAtomic(fs, recipientPath, []byte(ageRecipient)); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 4: Encrypt age private key with the generated passphrase and store on disk
|
|
||||||
// Create a secure buffer for the private key
|
|
||||||
agePrivKeyStr := ageIdentity.String()
|
|
||||||
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
|
|
||||||
defer agePrivKeyBuffer.Destroy()
|
defer agePrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer, agePrivKeyPassphrase)
|
encryptedAgePrivKey, err := EncryptWithPassphrase(
|
||||||
|
agePrivKeyBuffer, agePrivKeyPassphrase)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age")
|
// Step 4: Get or derive the long-term private key
|
||||||
if err := WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey); err != nil {
|
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
|
||||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 5: Get or derive the long-term private key
|
|
||||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 6: Encrypt long-term private key to the new age unlocker
|
// Step 5: Encrypt long-term private key to the new age unlocker
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
|
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
||||||
|
ltPrivKeyData, ageIdentity.Recipient())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
|
return nil, fmt.Errorf(
|
||||||
|
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write encrypted long-term private key
|
// Step 6: Prepare keychain data
|
||||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
||||||
if err := WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 7: Prepare keychain data
|
|
||||||
keychainData := KeychainData{
|
keychainData := KeychainData{
|
||||||
AgePublicKey: ageRecipient,
|
AgePublicKey: ageRecipient,
|
||||||
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
AgePrivKeyPassphrase: agePrivKeyPassphrase,
|
||||||
@@ -420,17 +464,25 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
}
|
}
|
||||||
defer keychainDataBuffer.Destroy()
|
defer keychainDataBuffer.Destroy()
|
||||||
|
|
||||||
// Step 8: Store data in keychain
|
return writeKeychainUnlocker(fs, unlockerDir, keychainItemName, ageRecipient,
|
||||||
if err := storeInKeychain(keychainItemName, keychainDataBuffer); err != nil {
|
encryptedAgePrivKey, encryptedLtPrivKeyToAge, keychainDataBuffer)
|
||||||
return nil, fmt.Errorf("failed to store data in keychain: %w", err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Step 9: Create and write enhanced metadata
|
// writeKeychainUnlocker writes a new keychain unlocker into unlockerDir and
|
||||||
|
// stores its data in the keychain (steps 7 and 8 of CreateKeychainUnlocker).
|
||||||
|
// The data is stored after the unlocker's files are written, and the keychain
|
||||||
|
// item is deleted again if moving the unlocker into place then fails.
|
||||||
|
func writeKeychainUnlocker(
|
||||||
|
fs afero.Fs, unlockerDir, keychainItemName, ageRecipient string,
|
||||||
|
encryptedAgePrivKey, encryptedLtPrivKey []byte,
|
||||||
|
keychainDataBuffer *memguard.LockedBuffer,
|
||||||
|
) (*KeychainUnlocker, error) {
|
||||||
|
// Step 7: Prepare enhanced metadata
|
||||||
keychainMetadata := KeychainUnlockerMetadata{
|
keychainMetadata := KeychainUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: "keychain",
|
Type: keychainUnlockerType,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Flags: []string{"keychain", "macos"},
|
Flags: []string{keychainUnlockerType, macOSFlag},
|
||||||
},
|
},
|
||||||
KeychainItemName: keychainItemName,
|
KeychainItemName: keychainItemName,
|
||||||
}
|
}
|
||||||
@@ -440,10 +492,51 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := WriteFileAtomic(fs,
|
// Step 8: Write the unlocker's files, the metadata last, then store the
|
||||||
filepath.Join(unlockerDir, "unlocker-metadata.json"),
|
// data in the keychain
|
||||||
metadataBytes); err != nil {
|
stored := false
|
||||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
||||||
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
|
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"), []byte(ageRecipient))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age"), encryptedAgePrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs, filepath.Join(dir, "unlocker-metadata.json"),
|
||||||
|
metadataBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = storeInKeychain(keychainItemName, keychainDataBuffer)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store data in keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored = true
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil && stored {
|
||||||
|
deleteErr := deleteFromKeychain(keychainItemName)
|
||||||
|
if deleteErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf(
|
||||||
|
"failed to delete keychain item %s: %w", keychainItemName, deleteErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &KeychainUnlocker{
|
return &KeychainUnlocker{
|
||||||
@@ -453,114 +546,15 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkMacOSAvailable verifies that we're running on macOS
|
// validateKeychainItemName validates that a keychain item name is safe for
|
||||||
func checkMacOSAvailable() error {
|
// command execution
|
||||||
if runtime.GOOS != "darwin" {
|
|
||||||
return fmt.Errorf("keychain unlockers are only supported on macOS, current OS: %s", runtime.GOOS)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateKeychainItemName validates that a keychain item name is safe for command execution
|
|
||||||
func validateKeychainItemName(itemName string) error {
|
func validateKeychainItemName(itemName string) error {
|
||||||
if itemName == "" {
|
if itemName == "" {
|
||||||
return fmt.Errorf("keychain item name cannot be empty")
|
return errKeychainItemNameEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
if !keychainItemNameRegex.MatchString(itemName) {
|
if !keychainItemNameRegex.MatchString(itemName) {
|
||||||
return fmt.Errorf("invalid keychain item name format: %s", itemName)
|
return fmt.Errorf("%w: %s", errInvalidKeychainItemName, itemName)
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
|
||||||
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
|
||||||
if data == nil {
|
|
||||||
return fmt.Errorf("data buffer is nil")
|
|
||||||
}
|
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
item := keychain.NewItem()
|
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
item.SetAccount(itemName)
|
|
||||||
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
|
||||||
item.SetDescription("Secret vault keychain data")
|
|
||||||
item.SetData(data.Bytes())
|
|
||||||
item.SetSynchronizable(keychain.SynchronizableNo)
|
|
||||||
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
|
||||||
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
|
||||||
|
|
||||||
// First try to delete any existing item
|
|
||||||
deleteItem := keychain.NewItem()
|
|
||||||
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
deleteItem.SetAccount(itemName)
|
|
||||||
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
|
||||||
|
|
||||||
// Add the new item
|
|
||||||
if err := keychain.AddItem(item); err != nil {
|
|
||||||
return fmt.Errorf("failed to store item in keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
|
||||||
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
query := keychain.NewItem()
|
|
||||||
query.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
query.SetAccount(itemName)
|
|
||||||
query.SetMatchLimit(keychain.MatchLimitOne)
|
|
||||||
query.SetReturnData(true)
|
|
||||||
|
|
||||||
results, err := keychain.QueryItem(query)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(results) == 0 {
|
|
||||||
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
|
||||||
}
|
|
||||||
|
|
||||||
return results[0].Data, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
|
||||||
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
|
||||||
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
|
||||||
func deleteFromKeychain(itemName string) error {
|
|
||||||
if err := validateKeychainItemName(itemName); err != nil {
|
|
||||||
return fmt.Errorf("invalid keychain item name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
item := keychain.NewItem()
|
|
||||||
item.SetSecClass(keychain.SecClassGenericPassword)
|
|
||||||
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
|
||||||
item.SetAccount(itemName)
|
|
||||||
|
|
||||||
if err := keychain.DeleteItem(item); err != nil {
|
|
||||||
// If the item doesn't exist, that's not an error - the goal is to ensure
|
|
||||||
// the item is gone, and it already being gone satisfies that goal.
|
|
||||||
// This is important for cleaning up unlocker directories when the keychain
|
|
||||||
// item has already been removed (e.g., manually by user, or synced vault
|
|
||||||
// from a different machine).
|
|
||||||
if err == keychain.ErrorItemNotFound {
|
|
||||||
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
//go:build darwin && cgo
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
keychain "github.com/keybase/go-keychain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The keychain unlocker's only calls into go-keychain, which is cgo on macOS.
|
||||||
|
// A macOS build without cgo gets keychainunlocker_nocgo.go instead.
|
||||||
|
|
||||||
|
// storeInKeychain stores data in the macOS keychain using keybase/go-keychain
|
||||||
|
func storeInKeychain(itemName string, data *memguard.LockedBuffer) error {
|
||||||
|
if data == nil {
|
||||||
|
return errNilDataBuffer
|
||||||
|
}
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
item.SetLabel(fmt.Sprintf("%s - %s", KEYCHAIN_APP_IDENTIFIER, itemName))
|
||||||
|
item.SetDescription("Secret vault keychain data")
|
||||||
|
item.SetData(data.Bytes())
|
||||||
|
item.SetSynchronizable(keychain.SynchronizableNo)
|
||||||
|
// Use AccessibleWhenUnlockedThisDeviceOnly for better security and to trigger auth
|
||||||
|
item.SetAccessible(keychain.AccessibleWhenUnlockedThisDeviceOnly)
|
||||||
|
|
||||||
|
// First try to delete any existing item
|
||||||
|
deleteItem := keychain.NewItem()
|
||||||
|
deleteItem.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
deleteItem.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
deleteItem.SetAccount(itemName)
|
||||||
|
_ = keychain.DeleteItem(deleteItem) // Ignore error as item might not exist
|
||||||
|
|
||||||
|
// Add the new item
|
||||||
|
if err := keychain.AddItem(item); err != nil {
|
||||||
|
return fmt.Errorf("failed to store item in keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieveFromKeychain retrieves data from the macOS keychain using keybase/go-keychain
|
||||||
|
func retrieveFromKeychain(itemName string) ([]byte, error) {
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := keychain.NewItem()
|
||||||
|
query.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
query.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
query.SetAccount(itemName)
|
||||||
|
query.SetMatchLimit(keychain.MatchLimitOne)
|
||||||
|
query.SetReturnData(true)
|
||||||
|
|
||||||
|
results, err := keychain.QueryItem(query)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to retrieve item from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(results) == 0 {
|
||||||
|
return nil, fmt.Errorf("keychain item not found: %s", itemName)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results[0].Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFromKeychain removes an item from the macOS keychain using keybase/go-keychain
|
||||||
|
// If the item doesn't exist, this function returns nil (not an error) since the goal
|
||||||
|
// is to ensure the item is gone, and it already being gone satisfies that goal.
|
||||||
|
func deleteFromKeychain(itemName string) error {
|
||||||
|
if err := validateKeychainItemName(itemName); err != nil {
|
||||||
|
return fmt.Errorf("invalid keychain item name: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
item := keychain.NewItem()
|
||||||
|
item.SetSecClass(keychain.SecClassGenericPassword)
|
||||||
|
item.SetService(KEYCHAIN_APP_IDENTIFIER)
|
||||||
|
item.SetAccount(itemName)
|
||||||
|
|
||||||
|
if err := keychain.DeleteItem(item); err != nil {
|
||||||
|
// If the item doesn't exist, that's not an error - the goal is to ensure
|
||||||
|
// the item is gone, and it already being gone satisfies that goal.
|
||||||
|
// This is important for cleaning up unlocker directories when the keychain
|
||||||
|
// item has already been removed (e.g., manually by user, or synced vault
|
||||||
|
// from a different machine).
|
||||||
|
if err == keychain.ErrorItemNotFound {
|
||||||
|
Debug("Keychain item not found during deletion, ignoring", "item_name", itemName)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to delete item from keychain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build darwin && !cgo
|
||||||
|
|
||||||
|
package secret
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// In a macOS build without cgo, these take the place of the functions in
|
||||||
|
// keychainunlocker_cgo.go: go-keychain is cgo on macOS, so they can only fail.
|
||||||
|
|
||||||
|
var errKeychainNotSupported = errors.New(
|
||||||
|
"keychain unlockers need a macOS build with cgo")
|
||||||
|
|
||||||
|
// storeInKeychain fails: the keychain needs a macOS build with cgo.
|
||||||
|
func storeInKeychain(_ string, _ *memguard.LockedBuffer) error {
|
||||||
|
return errKeychainNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieveFromKeychain fails: the keychain needs a macOS build with cgo.
|
||||||
|
func retrieveFromKeychain(_ string) ([]byte, error) {
|
||||||
|
return nil, errKeychainNotSupported
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteFromKeychain fails: the keychain needs a macOS build with cgo.
|
||||||
|
func deleteFromKeychain(_ string) error {
|
||||||
|
return errKeychainNotSupported
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -60,9 +61,9 @@ func (k *KeychainUnlocker) GetDirectory() string {
|
|||||||
return k.Directory
|
return k.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID returns the unlocker ID
|
// GetID returns the unlocker ID, the name of the unlocker's directory
|
||||||
func (k *KeychainUnlocker) GetID() string {
|
func (k *KeychainUnlocker) GetID() string {
|
||||||
return k.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-keychain"
|
return filepath.Base(k.Directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetKeychainItemName returns an error on non-Darwin platforms
|
// GetKeychainItemName returns an error on non-Darwin platforms
|
||||||
@@ -76,13 +77,8 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
// CreateKeychainUnlocker returns an error on non-Darwin platforms
|
||||||
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
func CreateKeychainUnlocker(
|
||||||
return nil, errKeychainNotSupported
|
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
|
||||||
}
|
) (*KeychainUnlocker, error) {
|
||||||
|
|
||||||
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
|
||||||
func getLongTermPrivateKey(
|
|
||||||
_ afero.Fs, _ VaultInterface,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
return nil, errKeychainNotSupported
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
//go:build darwin
|
//go:build darwin && cgo
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -35,7 +37,8 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
|||||||
// Test 2: Retrieve data from keychain
|
// Test 2: Retrieve data from keychain
|
||||||
retrievedData, err := retrieveFromKeychain(testItemName)
|
retrievedData, err := retrieveFromKeychain(testItemName)
|
||||||
require.NoError(t, err, "Failed to retrieve data from keychain")
|
require.NoError(t, err, "Failed to retrieve data from keychain")
|
||||||
assert.Equal(t, testData, string(retrievedData), "Retrieved data doesn't match stored data")
|
assert.Equal(t, testData, string(retrievedData),
|
||||||
|
"Retrieved data doesn't match stored data")
|
||||||
|
|
||||||
// Test 3: Update existing item (store again with different data)
|
// Test 3: Update existing item (store again with different data)
|
||||||
newTestData := "updated-test-data-67890"
|
newTestData := "updated-test-data-67890"
|
||||||
@@ -48,7 +51,8 @@ func TestKeychainStoreRetrieveDelete(t *testing.T) {
|
|||||||
// Verify updated data
|
// Verify updated data
|
||||||
retrievedData, err = retrieveFromKeychain(testItemName)
|
retrievedData, err = retrieveFromKeychain(testItemName)
|
||||||
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
require.NoError(t, err, "Failed to retrieve updated data from keychain")
|
||||||
assert.Equal(t, newTestData, string(retrievedData), "Retrieved data doesn't match updated data")
|
assert.Equal(t, newTestData, string(retrievedData),
|
||||||
|
"Retrieved data doesn't match updated data")
|
||||||
|
|
||||||
// Test 4: Delete from keychain
|
// Test 4: Delete from keychain
|
||||||
err = deleteFromKeychain(testItemName)
|
err = deleteFromKeychain(testItemName)
|
||||||
@@ -68,9 +72,12 @@ 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
|
||||||
@@ -92,8 +99,8 @@ func TestKeychainInvalidItemName(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range invalidNames {
|
for _, name := range invalidNames {
|
||||||
err := storeInKeychain(name, testData)
|
err := storeInKeychain(name, testData)
|
||||||
assert.Error(t, err, "Expected error for invalid name: %s", name)
|
require.ErrorIs(t, err, errInvalidKeychainItemName,
|
||||||
assert.Contains(t, err.Error(), "invalid keychain item name", "Error should mention invalid name for: %s", name)
|
"Expected error for invalid name: %s", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test valid names (should not error on validation)
|
// Test valid names (should not error on validation)
|
||||||
@@ -123,8 +130,7 @@ func TestKeychainNilData(t *testing.T) {
|
|||||||
|
|
||||||
// Test storing nil data
|
// Test storing nil data
|
||||||
err := storeInKeychain("test-item", nil)
|
err := storeInKeychain("test-item", nil)
|
||||||
assert.Error(t, err, "Expected error when storing nil data")
|
require.ErrorIs(t, err, errNilDataBuffer)
|
||||||
assert.Contains(t, err.Error(), "data buffer is nil")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestKeychainLargeData(t *testing.T) {
|
func TestKeychainLargeData(t *testing.T) {
|
||||||
@@ -180,5 +186,30 @@ func TestDeleteNonExistentKeychainItem(t *testing.T) {
|
|||||||
// This is important for cleaning up unlocker directories when the keychain item
|
// This is important for cleaning up unlocker directories when the keychain item
|
||||||
// has already been removed (e.g., manually by user, or on a different machine)
|
// has already been removed (e.g., manually by user, or on a different machine)
|
||||||
err := deleteFromKeychain(testItemName)
|
err := deleteFromKeychain(testItemName)
|
||||||
assert.NoError(t, err, "Deleting non-existent keychain item should not return an error")
|
assert.NoError(t, err,
|
||||||
|
"Deleting non-existent keychain item should not return an error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteKeychainUnlockerFailureDeletesItem makes moving a new keychain
|
||||||
|
// unlocker into place fail after its data is stored in the keychain: the
|
||||||
|
// keychain item must be deleted again.
|
||||||
|
func TestWriteKeychainUnlockerFailureDeletesItem(t *testing.T) {
|
||||||
|
testItemName := "test-secret-keychain-unlocker-cleanup"
|
||||||
|
_ = deleteFromKeychain(testItemName)
|
||||||
|
|
||||||
|
// Moving the unlocker into a read-only directory fails
|
||||||
|
unlockersDir := filepath.Join(t.TempDir(), "unlockers.d")
|
||||||
|
require.NoError(t, os.Mkdir(unlockersDir, 0o500))
|
||||||
|
|
||||||
|
testBuffer := memguard.NewBufferFromBytes([]byte("test-keychain-data"))
|
||||||
|
defer testBuffer.Destroy()
|
||||||
|
|
||||||
|
_, err := writeKeychainUnlocker(afero.NewOsFs(),
|
||||||
|
filepath.Join(unlockersDir, testItemName), testItemName, "age1test",
|
||||||
|
[]byte("test-priv"), []byte("test-longterm"), testBuffer)
|
||||||
|
require.ErrorIs(t, err, os.ErrPermission,
|
||||||
|
"moving the unlocker into place should fail")
|
||||||
|
|
||||||
|
_, err = retrieveFromKeychain(testItemName)
|
||||||
|
assert.Error(t, err, "keychain item left behind")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,17 @@ import (
|
|||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
||||||
"abandon abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
|
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
||||||
|
// destroyed when the test ends.
|
||||||
|
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
t.Cleanup(mnemonic.Destroy)
|
||||||
|
|
||||||
|
return mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
// writeTestPublicKey writes the unlocker public key and verifies it exists.
|
||||||
func writeTestPublicKey(
|
func writeTestPublicKey(
|
||||||
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
|
||||||
@@ -163,7 +174,7 @@ func newTestPassphraseUnlocker(
|
|||||||
return unlocker, ageIdentity, unlockerDir
|
return unlocker, ageIdentity, unlockerDir
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
|
//nolint:paralleltest // subtests share real-FS state, order matters
|
||||||
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
||||||
// This test uses real filesystem
|
// This test uses real filesystem
|
||||||
if os.Getenv("CI") == "true" {
|
if os.Getenv("CI") == "true" {
|
||||||
@@ -195,38 +206,42 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
|
|||||||
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set test environment variable (cleaned up automatically)
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
// Test getting identity from environment variable
|
unlocker.Passphrase = passphrase
|
||||||
t.Run("GetIdentityFromEnv", func(t *testing.T) {
|
|
||||||
identity, err := unlocker.GetIdentity()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get identity from env: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the identity matches what we expect
|
// Test getting identity with the passphrase the unlocker was given,
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
// twice: using it must leave it intact for the next use
|
||||||
|
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
|
||||||
|
for range 2 {
|
||||||
|
identity, err := unlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get identity with passphrase: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
actualPubKey := identity.Recipient().String()
|
// Verify the identity matches what we expect
|
||||||
if actualPubKey != expectedPubKey {
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
t.Errorf("Public key mismatch. Expected %s, got %s",
|
|
||||||
expectedPubKey, actualPubKey)
|
actualPubKey := identity.Recipient().String()
|
||||||
|
if actualPubKey != expectedPubKey {
|
||||||
|
t.Errorf("Public key mismatch. Expected %s, got %s",
|
||||||
|
expectedPubKey, actualPubKey)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unset the environment variable to test interactive prompt
|
unlocker.Passphrase = nil
|
||||||
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
|
|
||||||
|
|
||||||
// Test getting identity from prompt (this would require mocking the
|
// Test getting identity from prompt (this would require mocking the
|
||||||
// prompt). For real integration tests, we'd need a way to mock the
|
// prompt). For real integration tests, we'd need a way to mock the
|
||||||
// passphrase input. Here we just verify the error is what we expect
|
// passphrase input. Here we just verify the error is what we expect
|
||||||
// when no passphrase is available.
|
// when no passphrase is available.
|
||||||
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
|
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
|
||||||
// This should fail since we're not in an interactive terminal
|
// This should fail since we're not in an interactive terminal
|
||||||
_, err := unlocker.GetIdentity()
|
_, err := unlocker.GetIdentity()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("Should have failed to get identity without passphrase env var")
|
t.Errorf("Should have failed to get identity without a passphrase")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
@@ -11,6 +11,11 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrPassphraseNotRead is wrapped in 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
|
||||||
@@ -110,12 +115,9 @@ func (p *PassphraseUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from creation timestamp
|
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||||
func (p *PassphraseUnlocker) GetID() string {
|
func (p *PassphraseUnlocker) GetID() string {
|
||||||
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
|
return filepath.Base(p.Directory)
|
||||||
createdAt := p.Metadata.CreatedAt
|
|
||||||
|
|
||||||
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the passphrase unlocker
|
// Remove implements Unlocker interface - removes the passphrase unlocker
|
||||||
@@ -135,34 +137,25 @@ func (p *PassphraseUnlocker) Remove() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getPassphrase retrieves the passphrase from memory, environment, or
|
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
|
||||||
// user input. Returns a LockedBuffer for secure memory handling
|
// the passphrase. The caller must destroy the returned buffer.
|
||||||
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
|
||||||
// First check if we already have the passphrase
|
|
||||||
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
if p.Passphrase != nil && p.Passphrase.IsAlive() {
|
||||||
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
|
||||||
// Return a copy of the passphrase buffer
|
// Not NewBufferFromBytes, which would wipe p.Passphrase
|
||||||
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
|
passphrase := memguard.NewBuffer(p.Passphrase.Size())
|
||||||
|
passphrase.Copy(p.Passphrase.Bytes())
|
||||||
|
|
||||||
|
return passphrase, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
Debug("No passphrase in memory, checking environment")
|
Debug("No passphrase in memory, prompting user")
|
||||||
// Check environment variable for passphrase
|
|
||||||
passphraseStr := os.Getenv(EnvUnlockPassphrase)
|
|
||||||
if passphraseStr != "" {
|
|
||||||
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
|
|
||||||
// Convert to secure buffer
|
|
||||||
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
|
|
||||||
|
|
||||||
return secureBuffer, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("No passphrase in environment, prompting user")
|
|
||||||
// Prompt for passphrase
|
// Prompt for passphrase
|
||||||
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read passphrase: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return secureBuffer, nil
|
return secureBuffer, nil
|
||||||
|
|||||||
+409
-327
@@ -4,7 +4,9 @@ package secret_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -22,23 +24,24 @@ import (
|
|||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Register vault with secret package for testing
|
// pgpUnlockerType is the type of a PGP unlocker.
|
||||||
func init() {
|
const pgpUnlockerType = "pgp"
|
||||||
// Register the vault.GetCurrentVault function with the secret package
|
|
||||||
secret.RegisterGetCurrentVaultFunc(func(fs afero.Fs, stateDir string) (secret.VaultInterface, error) {
|
var errNilDataBuffer = errors.New("data buffer is nil")
|
||||||
return vault.GetCurrentVault(fs, stateDir)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
// setupNonInteractiveGPG creates a custom GPG environment for testing
|
||||||
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
func setupNonInteractiveGPG(t *testing.T, _, passphrase, gnupgHomeDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
// Create GPG config file for non-interactive operation
|
// Create GPG config file for non-interactive operation
|
||||||
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
gpgConfPath := filepath.Join(gnupgHomeDir, "gpg.conf")
|
||||||
gpgConfContent := `batch
|
gpgConfContent := `batch
|
||||||
no-tty
|
no-tty
|
||||||
pinentry-mode loopback
|
pinentry-mode loopback
|
||||||
`
|
`
|
||||||
if err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600); err != nil {
|
|
||||||
|
err := os.WriteFile(gpgConfPath, []byte(gpgConfContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Failed to write GPG config file: %v", err)
|
t.Fatalf("Failed to write GPG config file: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +50,15 @@ pinentry-mode loopback
|
|||||||
origDecryptFunc := secret.GPGDecryptFunc
|
origDecryptFunc := secret.GPGDecryptFunc
|
||||||
|
|
||||||
// Set custom GPG functions for this test
|
// Set custom GPG functions for this test
|
||||||
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
secret.GPGEncryptFunc = func(
|
||||||
|
data *memguard.LockedBuffer, keyID string,
|
||||||
|
) ([]byte, error) {
|
||||||
if data == nil {
|
if data == nil {
|
||||||
return nil, fmt.Errorf("data buffer is nil")
|
return nil, errNilDataBuffer
|
||||||
}
|
}
|
||||||
cmd := exec.Command("gpg",
|
|
||||||
|
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||||
|
cmd := exec.CommandContext(t.Context(), "gpg",
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
@@ -63,11 +70,13 @@ pinentry-mode loopback
|
|||||||
"-r", keyID)
|
"-r", keyID)
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(data.Bytes())
|
cmd.Stdin = bytes.NewReader(data.Bytes())
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG encryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +84,8 @@ pinentry-mode loopback
|
|||||||
}
|
}
|
||||||
|
|
||||||
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
secret.GPGDecryptFunc = func(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
||||||
cmd := exec.Command("gpg",
|
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||||
|
cmd := exec.CommandContext(t.Context(), "gpg",
|
||||||
"--homedir", gnupgHomeDir,
|
"--homedir", gnupgHomeDir,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
@@ -85,11 +95,13 @@ pinentry-mode loopback
|
|||||||
"--decrypt")
|
"--decrypt")
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
cmd.Stdin = bytes.NewReader(encryptedData)
|
cmd.Stdin = bytes.NewReader(encryptedData)
|
||||||
|
|
||||||
if err := cmd.Run(); err != nil {
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
return nil, fmt.Errorf("GPG decryption failed: %w\nStderr: %s", err, stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,20 +117,24 @@ pinentry-mode loopback
|
|||||||
}
|
}
|
||||||
|
|
||||||
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
// runGPGWithPassphrase executes a GPG command with the specified passphrase
|
||||||
func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.Reader) ([]byte, error) {
|
func runGPGWithPassphrase(
|
||||||
cmdArgs := []string{
|
ctx context.Context,
|
||||||
|
gnupgHome, passphrase string, args []string, input io.Reader,
|
||||||
|
) ([]byte, error) {
|
||||||
|
cmdArgs := append([]string{
|
||||||
"--homedir=" + gnupgHome,
|
"--homedir=" + gnupgHome,
|
||||||
"--batch",
|
"--batch",
|
||||||
"--yes",
|
"--yes",
|
||||||
"--pinentry-mode", "loopback",
|
"--pinentry-mode", "loopback",
|
||||||
"--passphrase", passphrase,
|
"--passphrase", passphrase,
|
||||||
}
|
}, args...)
|
||||||
cmdArgs = append(cmdArgs, args...)
|
|
||||||
|
|
||||||
cmd := exec.Command("gpg", cmdArgs...)
|
//nolint:gosec // G204: test runs gpg with test-controlled arguments
|
||||||
|
cmd := exec.CommandContext(ctx, "gpg", cmdArgs...)
|
||||||
cmd.Stdin = input
|
cmd.Stdin = input
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
cmd.Stdout = &stdout
|
cmd.Stdout = &stdout
|
||||||
cmd.Stderr = &stderr
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
@@ -130,14 +146,96 @@ func runGPGWithPassphrase(gnupgHome, passphrase string, args []string, input io.
|
|||||||
return stdout.Bytes(), nil
|
return stdout.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateTestGPGKey generates a GPG key protected by passphrase in
|
||||||
|
// gnupgHomeDir and returns its key ID and fingerprint.
|
||||||
|
func generateTestGPGKey(
|
||||||
|
t *testing.T, tempDir, gnupgHomeDir, passphrase string,
|
||||||
|
) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create GPG batch file for key generation
|
||||||
|
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
||||||
|
batchContent := `%echo Generating a test key
|
||||||
|
Key-Type: RSA
|
||||||
|
Key-Length: 2048
|
||||||
|
Name-Real: Test User
|
||||||
|
Name-Email: test@example.com
|
||||||
|
Expire-Date: 0
|
||||||
|
Passphrase: ` + passphrase + `
|
||||||
|
%commit
|
||||||
|
%echo Key generation completed
|
||||||
|
`
|
||||||
|
|
||||||
|
err := os.WriteFile(batchFile, []byte(batchContent), 0o600)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write batch file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate GPG key with batch mode
|
||||||
|
t.Log("Generating GPG key...")
|
||||||
|
|
||||||
|
_, err = runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
||||||
|
[]string{"--gen-key", batchFile}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate GPG key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("GPG key generated successfully")
|
||||||
|
|
||||||
|
// Get the key ID and fingerprint
|
||||||
|
output, err := runGPGWithPassphrase(t.Context(), gnupgHomeDir, passphrase,
|
||||||
|
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to list GPG keys: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse output to get key ID and fingerprint
|
||||||
|
var keyID, fingerprint string
|
||||||
|
|
||||||
|
for line := range strings.SplitSeq(string(output), "\n") {
|
||||||
|
if strings.HasPrefix(line, "sec:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 5 {
|
||||||
|
keyID = fields[4]
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(line, "fpr:") {
|
||||||
|
fields := strings.Split(line, ":")
|
||||||
|
if len(fields) >= 10 && fields[9] != "" {
|
||||||
|
fingerprint = fields[9]
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyID == "" {
|
||||||
|
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fingerprint == "" {
|
||||||
|
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Generated GPG key ID: %s", keyID)
|
||||||
|
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
||||||
|
|
||||||
|
return keyID, fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
||||||
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
func TestPGPUnlockerWithRealFS(t *testing.T) {
|
||||||
// Check if gpg is available
|
// Check if gpg is available
|
||||||
if _, err := exec.LookPath("gpg"); err != nil {
|
_, err := exec.LookPath("gpg")
|
||||||
|
if err != nil {
|
||||||
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
t.Log("GPG not available, PGP unlock key tests may not fully function")
|
||||||
// Continue anyway to test what we can
|
// Continue anyway to test what we can
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a temporary directory for our tests
|
// Create a temporary directory for our tests. Not t.TempDir: its longer
|
||||||
|
// path would put gpg-agent's socket in GNUPGHOME past the 104-byte limit
|
||||||
|
// macOS sets on socket paths.
|
||||||
|
//
|
||||||
|
//nolint:usetesting // see the comment above
|
||||||
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
tempDir, err := os.MkdirTemp("", "secret-pgp-test-")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create temp dir: %v", err)
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
@@ -146,7 +244,9 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
|
|
||||||
// Create a temporary GNUPGHOME
|
// Create a temporary GNUPGHOME
|
||||||
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
gnupgHomeDir := filepath.Join(tempDir, "gnupg")
|
||||||
if err := os.MkdirAll(gnupgHomeDir, 0o700); err != nil {
|
|
||||||
|
err = os.MkdirAll(gnupgHomeDir, 0o700)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
t.Fatalf("Failed to create GNUPGHOME: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,64 +259,7 @@ func TestPGPUnlockerWithRealFS(t *testing.T) {
|
|||||||
// Setup non-interactive GPG with custom functions
|
// Setup non-interactive GPG with custom functions
|
||||||
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
setupNonInteractiveGPG(t, tempDir, testPassphrase, gnupgHomeDir)
|
||||||
|
|
||||||
// Create GPG batch file for key generation
|
keyID, fingerprint := generateTestGPGKey(t, tempDir, gnupgHomeDir, testPassphrase)
|
||||||
batchFile := filepath.Join(tempDir, "gen-key-batch")
|
|
||||||
batchContent := `%echo Generating a test key
|
|
||||||
Key-Type: RSA
|
|
||||||
Key-Length: 2048
|
|
||||||
Name-Real: Test User
|
|
||||||
Name-Email: test@example.com
|
|
||||||
Expire-Date: 0
|
|
||||||
Passphrase: ` + testPassphrase + `
|
|
||||||
%commit
|
|
||||||
%echo Key generation completed
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(batchFile, []byte(batchContent), 0o600); err != nil {
|
|
||||||
t.Fatalf("Failed to write batch file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate GPG key with batch mode
|
|
||||||
t.Log("Generating GPG key...")
|
|
||||||
_, err = runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
|
||||||
[]string{"--gen-key", batchFile}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to generate GPG key: %v", err)
|
|
||||||
}
|
|
||||||
t.Log("GPG key generated successfully")
|
|
||||||
|
|
||||||
// Get the key ID and fingerprint
|
|
||||||
output, err := runGPGWithPassphrase(gnupgHomeDir, testPassphrase,
|
|
||||||
[]string{"--list-secret-keys", "--with-colons", "--fingerprint"}, nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to list GPG keys: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse output to get key ID and fingerprint
|
|
||||||
var keyID, fingerprint string
|
|
||||||
lines := strings.Split(string(output), "\n")
|
|
||||||
for _, line := range lines {
|
|
||||||
if strings.HasPrefix(line, "sec:") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= 5 {
|
|
||||||
keyID = fields[4]
|
|
||||||
}
|
|
||||||
} else if strings.HasPrefix(line, "fpr:") {
|
|
||||||
fields := strings.Split(line, ":")
|
|
||||||
if len(fields) >= 10 && fields[9] != "" {
|
|
||||||
fingerprint = fields[9]
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if keyID == "" {
|
|
||||||
t.Fatalf("Failed to find GPG key ID in output: %s", output)
|
|
||||||
}
|
|
||||||
if fingerprint == "" {
|
|
||||||
t.Fatalf("Failed to find GPG fingerprint in output: %s", output)
|
|
||||||
}
|
|
||||||
t.Logf("Generated GPG key ID: %s", keyID)
|
|
||||||
t.Logf("Generated GPG fingerprint: %s", fingerprint)
|
|
||||||
|
|
||||||
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
// Set the GPG_AGENT_INFO to empty to ensure gpg-agent doesn't interfere
|
||||||
t.Setenv("GPG_AGENT_INFO", "")
|
t.Setenv("GPG_AGENT_INFO", "")
|
||||||
@@ -224,11 +267,7 @@ Passphrase: ` + testPassphrase + `
|
|||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test data
|
|
||||||
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Set test environment variables
|
// Set test environment variables
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvGPGKeyID, keyID)
|
t.Setenv(secret.EnvGPGKeyID, keyID)
|
||||||
|
|
||||||
// Set up vault structure for testing
|
// Set up vault structure for testing
|
||||||
@@ -237,162 +276,20 @@ Passphrase: ` + testPassphrase + `
|
|||||||
|
|
||||||
// Test creation of a PGP unlock key through a vault
|
// Test creation of a PGP unlock key through a vault
|
||||||
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
t.Run("CreatePGPUnlocker", func(t *testing.T) {
|
||||||
// Set a limited test timeout to avoid hanging
|
testCreatePGPUnlocker(t, fs, stateDir, vaultName, keyID, fingerprint)
|
||||||
timer := time.AfterFunc(30*time.Second, func() {
|
|
||||||
t.Fatalf("Test timed out after 30 seconds")
|
|
||||||
})
|
|
||||||
defer timer.Stop()
|
|
||||||
|
|
||||||
// Create a test vault directory structure
|
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the current vault
|
|
||||||
err = vault.SelectVault(fs, stateDir, vaultName)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to select vault: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive long-term key from mnemonic
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to derive long-term key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the vault directory
|
|
||||||
vaultDir, err := vlt.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get vault directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write long-term public key
|
|
||||||
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
|
||||||
if err := afero.WriteFile(fs, ltPubKeyPath, []byte(ltIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
|
||||||
t.Fatalf("Failed to write long-term public key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unlock the vault
|
|
||||||
vlt.Unlock(ltIdentity)
|
|
||||||
|
|
||||||
// Create a passphrase unlocker first (to have current unlocker)
|
|
||||||
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
|
||||||
defer passphraseBuffer.Destroy()
|
|
||||||
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify passphrase unlocker was created
|
|
||||||
if passUnlocker == nil {
|
|
||||||
t.Fatal("Passphrase unlocker is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
|
||||||
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the PGP unlock key was created
|
|
||||||
if pgpUnlocker == nil {
|
|
||||||
t.Fatal("PGP unlock key is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the key has the correct type
|
|
||||||
if pgpUnlocker.GetType() != "pgp" {
|
|
||||||
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the key ID includes the GPG fingerprint
|
|
||||||
if !strings.Contains(pgpUnlocker.GetID(), fingerprint) {
|
|
||||||
t.Errorf("PGP unlock key ID '%s' does not contain GPG fingerprint '%s'", pgpUnlocker.GetID(), fingerprint)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the key directory exists
|
|
||||||
unlockerDir := pgpUnlocker.GetDirectory()
|
|
||||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
|
||||||
}
|
|
||||||
if !keyExists {
|
|
||||||
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if required files exist
|
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
||||||
recipientExists, err := afero.Exists(fs, recipientPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
|
||||||
}
|
|
||||||
if !recipientExists {
|
|
||||||
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
|
||||||
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if private key file exists: %v", err)
|
|
||||||
}
|
|
||||||
if !privKeyExists {
|
|
||||||
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
metadataExists, err := afero.Exists(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
|
||||||
}
|
|
||||||
if !metadataExists {
|
|
||||||
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
|
||||||
longtermExists, err := afero.Exists(fs, longtermPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
|
||||||
}
|
|
||||||
if !longtermExists {
|
|
||||||
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read and verify metadata
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to read metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
|
||||||
Flags []string `json:"flags"`
|
|
||||||
GPGKeyID string `json:"gpgKeyId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
t.Fatalf("Failed to parse metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.Type != "pgp" {
|
|
||||||
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
|
||||||
}
|
|
||||||
|
|
||||||
if metadata.GPGKeyID != fingerprint {
|
|
||||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set up key directory for individual tests
|
// Set up key directory for individual tests
|
||||||
unlockerDir := filepath.Join(tempDir, "unlocker")
|
unlockerDir := filepath.Join(tempDir, "unlocker")
|
||||||
if err := os.MkdirAll(unlockerDir, secret.DirPerms); err != nil {
|
|
||||||
|
err = os.MkdirAll(unlockerDir, secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Failed to create unlocker directory: %v", err)
|
t.Fatalf("Failed to create unlocker directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up test metadata
|
// Set up test metadata
|
||||||
metadata := secret.UnlockerMetadata{
|
metadata := secret.UnlockerMetadata{
|
||||||
Type: "pgp",
|
Type: pgpUnlockerType,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
Flags: []string{"gpg", "encrypted"},
|
Flags: []string{"gpg", "encrypted"},
|
||||||
}
|
}
|
||||||
@@ -400,107 +297,292 @@ Passphrase: ` + testPassphrase + `
|
|||||||
// Create a PGP unlocker for the remaining tests
|
// Create a PGP unlocker for the remaining tests
|
||||||
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
unlocker := secret.NewPGPUnlocker(fs, unlockerDir, metadata)
|
||||||
|
|
||||||
// Test getting GPG key ID
|
|
||||||
t.Run("GetGPGKeyID", func(t *testing.T) {
|
|
||||||
// Create PGP metadata with GPG key ID
|
|
||||||
type PGPUnlockerMetadata struct {
|
|
||||||
secret.UnlockerMetadata
|
|
||||||
GPGKeyID string `json:"gpgKeyId"`
|
|
||||||
}
|
|
||||||
|
|
||||||
pgpMetadata := PGPUnlockerMetadata{
|
|
||||||
UnlockerMetadata: metadata,
|
|
||||||
GPGKeyID: fingerprint,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write metadata file
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
|
||||||
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to marshal metadata: %v", err)
|
|
||||||
}
|
|
||||||
if err := afero.WriteFile(fs, metadataPath, metadataBytes, secret.FilePerms); err != nil {
|
|
||||||
t.Fatalf("Failed to write metadata: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get GPG key ID
|
|
||||||
retrievedKeyID, err := unlocker.GetGPGKeyID()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get GPG key ID: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify key ID (should be the fingerprint)
|
|
||||||
if retrievedKeyID != fingerprint {
|
|
||||||
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, retrievedKeyID)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Test getting identity from PGP unlocker
|
// Test getting identity from PGP unlocker
|
||||||
t.Run("GetIdentity", func(t *testing.T) {
|
t.Run("GetIdentity", func(t *testing.T) {
|
||||||
// Generate an age identity for testing
|
testPGPUnlockerGetIdentity(t, fs, unlocker, unlockerDir, keyID)
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to generate age identity: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the recipient
|
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
|
||||||
if err := afero.WriteFile(fs, recipientPath, []byte(ageIdentity.Recipient().String()), secret.FilePerms); err != nil {
|
|
||||||
t.Fatalf("Failed to write recipient: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GPG encrypt the private key using our custom encrypt function
|
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
|
||||||
defer privKeyBuffer.Destroy()
|
|
||||||
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the encrypted data to a file
|
|
||||||
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
|
||||||
if err := afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms); err != nil {
|
|
||||||
t.Fatalf("Failed to write encrypted private key: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now try to get the identity - this will use our custom GPGDecryptFunc
|
|
||||||
identity, err := unlocker.GetIdentity()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to get identity: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the identity matches
|
|
||||||
expectedPubKey := ageIdentity.Recipient().String()
|
|
||||||
actualPubKey := identity.Recipient().String()
|
|
||||||
if actualPubKey != expectedPubKey {
|
|
||||||
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test removing the unlocker
|
// Test removing the unlocker
|
||||||
t.Run("RemoveUnlocker", func(t *testing.T) {
|
t.Run("RemoveUnlocker", func(t *testing.T) {
|
||||||
// Ensure unlocker directory exists before removal
|
testRemovePGPUnlocker(t, fs, unlocker, unlockerDir)
|
||||||
keyExists, err := afero.DirExists(fs, unlockerDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
|
||||||
}
|
|
||||||
if !keyExists {
|
|
||||||
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove unlocker
|
|
||||||
err = unlocker.Remove()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to remove unlocker: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify directory is gone
|
|
||||||
keyExists, err = afero.DirExists(fs, unlockerDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
|
||||||
}
|
|
||||||
if keyExists {
|
|
||||||
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testCreatePGPUnlocker creates a vault with a passphrase unlocker, then a
|
||||||
|
// PGP unlocker for the GPG key keyID, and checks the PGP unlocker's files
|
||||||
|
// and metadata.
|
||||||
|
func testCreatePGPUnlocker(
|
||||||
|
t *testing.T, fs afero.Fs, stateDir, vaultName, keyID, fingerprint string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Set a limited test timeout to avoid hanging
|
||||||
|
timer := time.AfterFunc(30*time.Second, func() {
|
||||||
|
t.Fatalf("Test timed out after 30 seconds")
|
||||||
|
})
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
|
// Create a test vault directory structure
|
||||||
|
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the current vault
|
||||||
|
err = vault.SelectVault(fs, stateDir, vaultName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to select vault: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive long-term key from mnemonic
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive long-term key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the vault directory
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get vault directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write long-term public key
|
||||||
|
ltPubKeyPath := filepath.Join(vaultDir, "pub.age")
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, ltPubKeyPath,
|
||||||
|
[]byte(ltIdentity.Recipient().String()), secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write long-term public key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the vault
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create a passphrase unlocker first (to have current unlocker)
|
||||||
|
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||||
|
defer passphraseBuffer.Destroy()
|
||||||
|
|
||||||
|
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create passphrase unlocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify passphrase unlocker was created
|
||||||
|
if passUnlocker == nil {
|
||||||
|
t.Fatal("Passphrase unlocker is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
|
||||||
|
pgpUnlocker, err := secret.CreatePGPUnlocker(
|
||||||
|
fs, stateDir, keyID, fingerprint, mnemonic, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create PGP unlock key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the PGP unlock key was created
|
||||||
|
if pgpUnlocker == nil {
|
||||||
|
t.Fatal("PGP unlock key is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the key has the correct type
|
||||||
|
if pgpUnlocker.GetType() != pgpUnlockerType {
|
||||||
|
t.Errorf("Expected PGP unlock key type 'pgp', got '%s'", pgpUnlocker.GetType())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that the ID is the name of the unlocker's directory
|
||||||
|
if pgpUnlocker.GetID() != filepath.Base(pgpUnlocker.GetDirectory()) {
|
||||||
|
t.Errorf("PGP unlock key ID '%s' is not its directory name '%s'",
|
||||||
|
pgpUnlocker.GetID(), filepath.Base(pgpUnlocker.GetDirectory()))
|
||||||
|
}
|
||||||
|
|
||||||
|
checkPGPUnlockerFiles(t, fs, pgpUnlocker.GetDirectory())
|
||||||
|
checkPGPUnlockerMetadata(t, fs, pgpUnlocker.GetDirectory(), fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPGPUnlockerFiles checks that the PGP unlocker in unlockerDir has all
|
||||||
|
// its files.
|
||||||
|
func checkPGPUnlockerFiles(t *testing.T, fs afero.Fs, unlockerDir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Check if the key directory exists
|
||||||
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if PGP key directory exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keyExists {
|
||||||
|
t.Errorf("PGP unlock key directory does not exist: %s", unlockerDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if required files exist
|
||||||
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
|
||||||
|
recipientExists, err := afero.Exists(fs, recipientPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if recipient file exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !recipientExists {
|
||||||
|
t.Errorf("PGP unlock key recipient file does not exist: %s", recipientPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
privKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
|
||||||
|
privKeyExists, err := afero.Exists(fs, privKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if private key file exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !privKeyExists {
|
||||||
|
t.Errorf("PGP unlock key private key file does not exist: %s", privKeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
||||||
|
|
||||||
|
metadataExists, err := afero.Exists(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if metadata file exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !metadataExists {
|
||||||
|
t.Errorf("PGP unlock key metadata file does not exist: %s", metadataPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
longtermPath := filepath.Join(unlockerDir, "longterm.age")
|
||||||
|
|
||||||
|
longtermExists, err := afero.Exists(fs, longtermPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if longterm key file exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !longtermExists {
|
||||||
|
t.Errorf("PGP unlock key longterm key file does not exist: %s", longtermPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkPGPUnlockerMetadata checks that the metadata of the PGP unlocker in
|
||||||
|
// unlockerDir names its type and the GPG key by fingerprint.
|
||||||
|
func checkPGPUnlockerMetadata(
|
||||||
|
t *testing.T, fs afero.Fs, unlockerDir, fingerprint string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Read and verify metadata
|
||||||
|
metadataPath := filepath.Join(unlockerDir, unlockerMetadataFile)
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to read metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
Flags []string `json:"flags"`
|
||||||
|
GPGKeyID string `json:"gpgKeyId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to parse metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.Type != pgpUnlockerType {
|
||||||
|
t.Errorf("Expected metadata type 'pgp', got '%s'", metadata.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.GPGKeyID != fingerprint {
|
||||||
|
t.Errorf("Expected GPG fingerprint '%s', got '%s'", fingerprint, metadata.GPGKeyID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testPGPUnlockerGetIdentity writes an age identity encrypted to the GPG key
|
||||||
|
// keyID into unlockerDir and checks that unlocker decrypts it.
|
||||||
|
func testPGPUnlockerGetIdentity(
|
||||||
|
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker,
|
||||||
|
unlockerDir, keyID string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Generate an age identity for testing
|
||||||
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to generate age identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the recipient
|
||||||
|
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, recipientPath,
|
||||||
|
[]byte(ageIdentity.Recipient().String()), secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write recipient: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPG encrypt the private key using our custom encrypt function
|
||||||
|
privKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
||||||
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedOutput, err := secret.GPGEncryptFunc(privKeyBuffer, keyID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encrypt with GPG: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the encrypted data to a file
|
||||||
|
encryptedPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, encryptedPath, encryptedOutput, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to write encrypted private key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to get the identity - this will use our custom GPGDecryptFunc
|
||||||
|
identity, err := unlocker.GetIdentity()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get identity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the identity matches
|
||||||
|
expectedPubKey := ageIdentity.Recipient().String()
|
||||||
|
actualPubKey := identity.Recipient().String()
|
||||||
|
|
||||||
|
if actualPubKey != expectedPubKey {
|
||||||
|
t.Errorf("Expected public key '%s', got '%s'", expectedPubKey, actualPubKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRemovePGPUnlocker removes unlocker and checks that unlockerDir is gone.
|
||||||
|
func testRemovePGPUnlocker(
|
||||||
|
t *testing.T, fs afero.Fs, unlocker *secret.PGPUnlocker, unlockerDir string,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Ensure unlocker directory exists before removal
|
||||||
|
keyExists, err := afero.DirExists(fs, unlockerDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !keyExists {
|
||||||
|
t.Fatalf("Unlocker directory does not exist: %s", unlockerDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove unlocker
|
||||||
|
err = unlocker.Remove()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to remove unlocker: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify directory is gone
|
||||||
|
keyExists, err = afero.DirExists(fs, unlockerDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check if unlocker directory exists: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if keyExists {
|
||||||
|
t.Errorf("Unlocker directory still exists after removal: %s", unlockerDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+126
-139
@@ -18,6 +18,10 @@ 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")
|
||||||
@@ -25,6 +29,10 @@ 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
|
||||||
@@ -155,21 +163,9 @@ func (p *PGPUnlocker) GetDirectory() string {
|
|||||||
return p.Directory
|
return p.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
// GetID implements Unlocker interface: the name of the unlocker's directory
|
||||||
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
|
||||||
// directory and returns "pgp-unknown", so listing the other unlockers
|
|
||||||
// still works.
|
|
||||||
func (p *PGPUnlocker) GetID() string {
|
func (p *PGPUnlocker) GetID() string {
|
||||||
// Generate ID using GPG key ID: pgp-<keyid>
|
return filepath.Base(p.Directory)
|
||||||
gpgKeyID, err := p.GetGPGKeyID()
|
|
||||||
if err != nil {
|
|
||||||
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
|
||||||
"directory", p.Directory, "error", err)
|
|
||||||
|
|
||||||
return "pgp-unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
return "pgp-" + gpgKeyID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface - removes the PGP unlocker
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
||||||
@@ -184,172 +180,85 @@ func (p *PGPUnlocker) Remove() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGPGKeyID returns the GPG key ID from metadata
|
|
||||||
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
|
||||||
// Load the metadata
|
|
||||||
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pgpMetadata PGPUnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataData, &pgpMetadata)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if pgpMetadata.GPGKeyID == "" {
|
|
||||||
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
|
||||||
}
|
|
||||||
|
|
||||||
return pgpMetadata.GPGKeyID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
||||||
// based on hostname and date
|
// based on hostname and time
|
||||||
func generatePGPUnlockerName() (string, error) {
|
func generatePGPUnlockerName() (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format: hostname-pgp-YYYY-MM-DD
|
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||||
enrollmentDate := time.Now().Format("2006-01-02")
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// preparePGPUnlockerDir checks GPG availability and creates the
|
// pgpUnlockerDir returns the current vault and the directory in it for a
|
||||||
// unlocker directory in the current vault, returning the vault and the
|
// new PGP unlocker, named after the host and the time.
|
||||||
// directory path.
|
|
||||||
//
|
//
|
||||||
//nolint:ireturn // the vault is only available behind VaultInterface
|
//nolint:ireturn // the vault is only available behind VaultInterface
|
||||||
func preparePGPUnlockerDir(
|
func pgpUnlockerDir(
|
||||||
fs afero.Fs, stateDir string,
|
fs afero.Fs, stateDir string,
|
||||||
) (VaultInterface, string, error) {
|
) (VaultInterface, string, error) {
|
||||||
// Check if GPG is available
|
|
||||||
err := checkGPGAvailable()
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current vault
|
// Get current vault
|
||||||
vault, err := GetCurrentVault(fs, stateDir)
|
vault, err := GetCurrentVault(fs, stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the unlocker name based on hostname and date
|
// Generate the unlocker name based on hostname and time
|
||||||
unlockerName, err := generatePGPUnlockerName()
|
unlockerName, err := generatePGPUnlockerName()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create unlocker directory using the generated name
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
vaultDir, err := vault.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
|
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
||||||
|
|
||||||
err = fs.MkdirAll(unlockerDir, DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf("failed to create unlocker directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return vault, unlockerDir, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
|
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
|
||||||
|
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
|
||||||
|
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
|
||||||
|
// Everything that can fail short of writing a file is done before anything
|
||||||
|
// is written, and the files are written through WriteDir, so a failure
|
||||||
|
// leaves no partial unlocker. The long-term key comes from mnemonic when it
|
||||||
|
// is not nil, else from the current unlocker, which is given passphrase when
|
||||||
|
// it is a passphrase unlocker.
|
||||||
func CreatePGPUnlocker(
|
func CreatePGPUnlocker(
|
||||||
fs afero.Fs, stateDir string, gpgKeyID string,
|
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*PGPUnlocker, error) {
|
) (*PGPUnlocker, error) {
|
||||||
vault, unlockerDir, err := preparePGPUnlockerDir(fs, stateDir)
|
err := checkGPGAvailable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
|
||||||
|
vault.SetMnemonic(mnemonic)
|
||||||
|
vault.SetUnlockPassphrase(passphrase)
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the PGP unlocker
|
// Step 1: Generate a new age keypair for the PGP unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Store age recipient as plaintext
|
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
||||||
ageRecipient := ageIdentity.Recipient().String()
|
// keypair's private key to the GPG key
|
||||||
recipientPath := filepath.Join(unlockerDir, "pub.txt")
|
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
||||||
|
vault, ageIdentity, gpgKeyID)
|
||||||
err = WriteFileAtomic(fs, recipientPath, []byte(ageRecipient))
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write age recipient: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 3: Get or derive the long-term private key
|
|
||||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
|
||||||
|
|
||||||
// Step 7: Encrypt long-term private key to the new age unlocker
|
|
||||||
encryptedLtPrivKeyToAge, err := EncryptToRecipient(
|
|
||||||
ltPrivKeyData, ageIdentity.Recipient())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write encrypted long-term private key
|
|
||||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 8: Encrypt age private key to the GPG key ID
|
|
||||||
// Use memguard to protect the private key in memory
|
|
||||||
agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String()))
|
|
||||||
defer agePrivateKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
|
|
||||||
|
|
||||||
err = WriteFileAtomic(fs, agePrivKeyPath, encryptedAgePrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steps 9-10: Resolve the fingerprint and write enhanced metadata
|
|
||||||
pgpMetadata, err := writePGPUnlockerMetadata(fs, unlockerDir, gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &PGPUnlocker{
|
|
||||||
Directory: unlockerDir,
|
|
||||||
Metadata: pgpMetadata.UnlockerMetadata,
|
|
||||||
fs: fs,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// writePGPUnlockerMetadata resolves the GPG key fingerprint and writes
|
|
||||||
// the unlocker metadata file, returning the metadata written.
|
|
||||||
func writePGPUnlockerMetadata(
|
|
||||||
fs afero.Fs, unlockerDir string, gpgKeyID string,
|
|
||||||
) (*PGPUnlockerMetadata, error) {
|
|
||||||
fingerprint, err := ResolveGPGKeyFingerprint(gpgKeyID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
pgpMetadata := PGPUnlockerMetadata{
|
pgpMetadata := PGPUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
@@ -365,13 +274,85 @@ func writePGPUnlockerMetadata(
|
|||||||
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = WriteFileAtomic(fs,
|
// Step 3: Write the unlocker's files, the metadata last
|
||||||
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
|
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
|
||||||
|
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &pgpMetadata, nil
|
return &PGPUnlocker{
|
||||||
|
Directory: unlockerDir,
|
||||||
|
Metadata: pgpMetadata.UnlockerMetadata,
|
||||||
|
fs: fs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
|
||||||
|
// to the new PGP unlocker's age keypair, and that keypair's private key
|
||||||
|
// encrypted to the GPG key gpgKeyID.
|
||||||
|
func encryptPGPUnlockerKeys(
|
||||||
|
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
||||||
|
) ([]byte, []byte, error) {
|
||||||
|
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
||||||
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
|
||||||
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
|
encryptedLtPrivKey, err := EncryptToRecipient(
|
||||||
|
ltPrivKeyData, ageIdentity.Recipient())
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"failed to encrypt long-term private key to age unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
||||||
|
defer agePrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf(
|
||||||
|
"failed to encrypt age private key with GPG: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return encryptedLtPrivKey, encryptedAgePrivKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
|
||||||
|
// metadata last.
|
||||||
|
func writePGPUnlockerFiles(
|
||||||
|
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
|
||||||
|
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
|
||||||
|
) error {
|
||||||
|
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
|
||||||
|
[]byte(ageRecipient.String()))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write age recipient: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs,
|
||||||
|
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
||||||
@@ -394,14 +375,20 @@ 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
|
// Use GPG to get the full fingerprint for the key. --status-fd 1 adds
|
||||||
|
// 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", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
"gpg", "--status-fd", "1",
|
||||||
|
"--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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package secret_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The GPG key ID and fingerprint passed to CreatePGPUnlocker.
|
||||||
|
const (
|
||||||
|
testGPGKeyID = "0123456789ABCDEF"
|
||||||
|
testGPGFingerprint = "0123456789ABCDEF0123456789ABCDEF01234567"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeGPGScript is a gpg for which `gpg --version` succeeds and anything
|
||||||
|
// else fails.
|
||||||
|
const fakeGPGScript = `#!/bin/sh
|
||||||
|
[ "$*" = --version ]
|
||||||
|
`
|
||||||
|
|
||||||
|
// installFakeGPG makes fakeGPGScript the only gpg on PATH for the test.
|
||||||
|
func installFakeGPG(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
//nolint:gosec // G306: the script must be executable
|
||||||
|
err := os.WriteFile(filepath.Join(dir, "gpg"), []byte(fakeGPGScript), 0o700)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
|
||||||
|
// getting the vault's long-term key, which used to come after part of the
|
||||||
|
// unlocker was written, and asserts that nothing is written. Getting the key
|
||||||
|
// fails because there is no mnemonic and no current unlocker.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
||||||
|
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
||||||
|
installFakeGPG(t)
|
||||||
|
|
||||||
|
base := afero.NewMemMapFs()
|
||||||
|
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
fs := hookFs{Fs: base, before: func(_, path string) error {
|
||||||
|
t.Errorf("changed %s", path)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
|
||||||
|
_, err = secret.CreatePGPUnlocker(
|
||||||
|
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, dirNames(t, base, filepath.Join(vaultDir, "unlockers.d")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGPUnlockerAddedTwiceKeepsFirst adds two PGP unlockers one right after
|
||||||
|
// the other, so on the same host and day, and checks that the second gets a
|
||||||
|
// directory of its own and leaves the first one's files as they were.
|
||||||
|
// CreatePGPUnlocker does not check whether the GPG key already has an
|
||||||
|
// unlocker, so the test key serves for both.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
||||||
|
func TestPGPUnlockerAddedTwiceKeepsFirst(t *testing.T) {
|
||||||
|
installFakeGPG(t)
|
||||||
|
|
||||||
|
original := secret.GPGEncryptFunc
|
||||||
|
|
||||||
|
t.Cleanup(func() { secret.GPGEncryptFunc = original })
|
||||||
|
|
||||||
|
// Stands in for gpg, which the test does not have: "encrypts" by copying
|
||||||
|
secret.GPGEncryptFunc = func(data *memguard.LockedBuffer, _ string) ([]byte, error) {
|
||||||
|
return []byte(data.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
_, err := vault.CreateVault(fs, testVaultStateDir, testVaultName, mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
first, err := secret.CreatePGPUnlocker(
|
||||||
|
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
firstFiles := dirFiles(t, fs, first.GetDirectory())
|
||||||
|
|
||||||
|
second, err := secret.CreatePGPUnlocker(
|
||||||
|
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, mnemonic, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotEqual(t, first.GetDirectory(), second.GetDirectory())
|
||||||
|
assert.Equal(t, firstFiles, dirFiles(t, fs, first.GetDirectory()))
|
||||||
|
}
|
||||||
+6
-192
@@ -1,27 +1,18 @@
|
|||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
// errSecretNotFound carries only the message tail; callers compose
|
|
||||||
// "secret <name> not found" around it so the emitted text is
|
|
||||||
// unchanged.
|
|
||||||
errSecretNotFound = errors.New("not found")
|
|
||||||
errUnlockerRequired = errors.New("unlocker required to decrypt secret")
|
|
||||||
errGetEncryptedDataDeprecated = errors.New(
|
errGetEncryptedDataDeprecated = errors.New(
|
||||||
"GetEncryptedData is deprecated - use version-specific methods")
|
"GetEncryptedData is deprecated - use version-specific methods")
|
||||||
errGetCurrentVaultNotRegistered = errors.New(
|
errGetCurrentVaultNotRegistered = errors.New(
|
||||||
@@ -35,6 +26,12 @@ type VaultInterface interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
|
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
||||||
|
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
|
||||||
|
// mnemonic to derive the long-term key from, and the passphrase for a
|
||||||
|
// current passphrase unlocker; nil for none.
|
||||||
|
SetMnemonic(mnemonic *memguard.LockedBuffer)
|
||||||
|
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
|
||||||
CreatePassphraseUnlocker(
|
CreatePassphraseUnlocker(
|
||||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
}
|
}
|
||||||
@@ -76,71 +73,6 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetValue retrieves and decrypts the current version's value using the
|
|
||||||
// provided unlocker
|
|
||||||
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
|
|
||||||
DebugWith("Getting secret value",
|
|
||||||
slog.String("secret_name", s.Name),
|
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Check if secret exists
|
|
||||||
exists, err := s.Exists()
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to check if secret exists during GetValue",
|
|
||||||
"error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to check if secret exists: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
Debug("Secret not found during GetValue",
|
|
||||||
"secret_name", s.Name, "vault_name", s.vault.GetName())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("secret %s %w", s.Name, errSecretNotFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Secret exists, getting current version", "secret_name", s.Name)
|
|
||||||
|
|
||||||
// Get current version
|
|
||||||
currentVersion, err := GetCurrentVersion(s.vault.GetFilesystem(), s.Directory)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to get current version", "error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create version object
|
|
||||||
version := NewVersion(s.vault, s.Name, currentVersion)
|
|
||||||
|
|
||||||
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
|
|
||||||
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
|
|
||||||
return s.getValueViaMnemonic(version, envMnemonic)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Using unlocker for vault access", "secret_name", s.Name)
|
|
||||||
|
|
||||||
// Use the provided unlocker to get the vault's long-term private key
|
|
||||||
if unlocker == nil {
|
|
||||||
Debug("No unlocker provided for secret decryption", "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, errUnlockerRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
ltIdentity, err := s.getLongTermIdentityFromUnlocker(unlocker)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugWith("Successfully obtained vault's long-term key",
|
|
||||||
slog.String("secret_name", s.Name),
|
|
||||||
slog.String("public_key", ltIdentity.Recipient().String()),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use the long-term key to decrypt the version
|
|
||||||
return version.GetValue(ltIdentity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||||
func (s *Secret) LoadMetadata() error {
|
func (s *Secret) LoadMetadata() error {
|
||||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||||
@@ -208,124 +140,6 @@ func (s *Secret) Exists() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getValueViaMnemonic derives the vault's long-term key from the
|
|
||||||
// mnemonic in the environment and decrypts the version value with it.
|
|
||||||
func (s *Secret) getValueViaMnemonic(
|
|
||||||
version *Version, envMnemonic string,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
Debug("Using mnemonic from environment for direct long-term key derivation",
|
|
||||||
"secret_name", s.Name)
|
|
||||||
|
|
||||||
// Get vault directory to read metadata
|
|
||||||
vaultDir, err := s.vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to get vault directory", "error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load vault metadata to get the correct derivation index
|
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(s.vault.GetFilesystem(), metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to read vault metadata", "error", err, "path", metadataPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata VaultMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to parse vault metadata", "error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
DebugWith("Using vault derivation index from metadata",
|
|
||||||
slog.String("secret_name", s.Name),
|
|
||||||
slog.String("vault_name", s.vault.GetName()),
|
|
||||||
slog.Uint64("derivation_index", uint64(metadata.DerivationIndex)),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Use mnemonic with the vault's derivation index from metadata
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to derive long-term key from mnemonic for secret",
|
|
||||||
"error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to derive long-term key from mnemonic: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Successfully derived long-term key from mnemonic", "secret_name", s.Name)
|
|
||||||
|
|
||||||
// Use the long-term key to decrypt the version
|
|
||||||
return version.GetValue(ltIdentity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getLongTermIdentityFromUnlocker uses the unlocker to obtain and parse
|
|
||||||
// the vault's long-term private key.
|
|
||||||
func (s *Secret) getLongTermIdentityFromUnlocker(
|
|
||||||
unlocker Unlocker,
|
|
||||||
) (*age.X25519Identity, error) {
|
|
||||||
DebugWith("Getting vault's long-term key using unlocker",
|
|
||||||
slog.String("secret_name", s.Name),
|
|
||||||
slog.String("unlocker_type", unlocker.GetType()),
|
|
||||||
slog.String("unlocker_id", unlocker.GetID()),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 1: Use the unlocker to get the vault's long-term private key
|
|
||||||
unlockIdentity, err := unlocker.GetIdentity()
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to get unlocker identity",
|
|
||||||
"error", err, "secret_name", s.Name,
|
|
||||||
"unlocker_type", unlocker.GetType())
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get unlocker identity: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the encrypted long-term private key from the unlocker directory
|
|
||||||
encryptedLtPrivKeyPath := filepath.Join(unlocker.GetDirectory(), "longterm.age")
|
|
||||||
Debug("Reading encrypted long-term private key", "path", encryptedLtPrivKeyPath)
|
|
||||||
|
|
||||||
encryptedLtPrivKey, err := afero.ReadFile(
|
|
||||||
s.vault.GetFilesystem(), encryptedLtPrivKeyPath)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to read encrypted long-term private key",
|
|
||||||
"error", err, "path", encryptedLtPrivKeyPath)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to read encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the encrypted long-term private key using the unlocker
|
|
||||||
Debug("Decrypting long-term private key using unlocker", "secret_name", s.Name)
|
|
||||||
|
|
||||||
ltPrivKeyBuffer, err := DecryptWithIdentity(encryptedLtPrivKey, unlockIdentity)
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to decrypt long-term private key",
|
|
||||||
"error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
defer ltPrivKeyBuffer.Destroy()
|
|
||||||
|
|
||||||
// Parse the long-term private key
|
|
||||||
Debug("Parsing long-term private key", "secret_name", s.Name)
|
|
||||||
|
|
||||||
ltIdentity, err := age.ParseX25519Identity(ltPrivKeyBuffer.String())
|
|
||||||
if err != nil {
|
|
||||||
Debug("Failed to parse long-term private key",
|
|
||||||
"error", err, "secret_name", s.Name)
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to parse long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ltIdentity, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetCurrentVault gets the current vault from the file system
|
// GetCurrentVault gets the current vault from the file system
|
||||||
// This function is a wrapper around the actual implementation in the vault package
|
// This function is a wrapper around the actual implementation in the vault package
|
||||||
// and exists to break the import cycle.
|
// and exists to break the import cycle.
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
// testMnemonicValue is the standard BIP39 test vector mnemonic.
|
||||||
@@ -22,7 +21,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
|
|||||||
"abandon abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon abandon about"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
|
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
|
||||||
errNotImplementedInMock = errors.New("not implemented in mock")
|
errNotImplementedInMock = errors.New("not implemented in mock")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,6 +31,7 @@ type MockVault struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
directory string
|
directory string
|
||||||
derivationIndex uint32
|
derivationIndex uint32
|
||||||
|
mnemonic *memguard.LockedBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockVault) GetDirectory() (string, error) {
|
func (m *MockVault) GetDirectory() (string, error) {
|
||||||
@@ -61,12 +61,11 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
|
|||||||
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
|
||||||
|
|
||||||
// Derive long-term key using the vault's derivation index
|
// Derive long-term key using the vault's derivation index
|
||||||
mnemonic := os.Getenv(EnvMnemonic)
|
if m.mnemonic == nil {
|
||||||
if mnemonic == "" {
|
|
||||||
return errMnemonicNotSet
|
return errMnemonicNotSet
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -107,6 +106,16 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
|||||||
return nil, errNotImplementedInMock
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
|
return nil, errNotImplementedInMock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||||
|
m.mnemonic = mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||||
|
|
||||||
func (m *MockVault) CreatePassphraseUnlocker(
|
func (m *MockVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*PassphraseUnlocker, error) {
|
) (*PassphraseUnlocker, error) {
|
||||||
@@ -234,13 +243,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // uses t.Setenv (process-global environment)
|
//nolint:paralleltest // subtests share one vault, order matters
|
||||||
func TestPerSecretKeyFunctionality(t *testing.T) {
|
func TestPerSecretKeyFunctionality(t *testing.T) {
|
||||||
// Create an in-memory filesystem for testing
|
// Create an in-memory filesystem for testing
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set test mnemonic for direct encryption/decryption
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
|
||||||
t.Setenv(EnvMnemonic, testMnemonicValue)
|
defer mnemonic.Destroy()
|
||||||
|
|
||||||
// Set up a test vault structure
|
// Set up a test vault structure
|
||||||
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
baseDir := "/test-config/berlin.sneak.pkg.secret"
|
||||||
@@ -254,6 +263,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
fs: fs,
|
fs: fs,
|
||||||
directory: vaultDir,
|
directory: vaultDir,
|
||||||
derivationIndex: 0,
|
derivationIndex: 0,
|
||||||
|
mnemonic: mnemonic,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test data
|
// Test data
|
||||||
@@ -309,27 +319,3 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
|
|||||||
t.Logf("Secret.Exists() works correctly")
|
t.Logf("Secret.Exists() works correctly")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
|
|
||||||
// This test demonstrates the bug where GetValue uses hardcoded index 0
|
|
||||||
// instead of the vault's actual derivation index when using environment mnemonic
|
|
||||||
|
|
||||||
// Set up test mnemonic
|
|
||||||
t.Setenv(EnvMnemonic, testMnemonicValue)
|
|
||||||
|
|
||||||
// Create temporary directory for vaults
|
|
||||||
fs := afero.NewOsFs()
|
|
||||||
tempDir, err := afero.TempDir(fs, "", "secret-test-")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
_ = fs.RemoveAll(tempDir)
|
|
||||||
}()
|
|
||||||
|
|
||||||
stateDir := filepath.Join(tempDir, ".secret")
|
|
||||||
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
|
|
||||||
|
|
||||||
// This test is now in the integration test file where it can use real vaults
|
|
||||||
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
|
|
||||||
t.Log("This test demonstrates the bug in the integration test file")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,7 +13,6 @@ import (
|
|||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/macse"
|
"git.eeqj.de/sneak/secret/internal/macse"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
@@ -32,6 +31,7 @@ const (
|
|||||||
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
// SecureEnclaveUnlockerMetadata extends UnlockerMetadata with SE-specific data.
|
||||||
type SecureEnclaveUnlockerMetadata struct {
|
type SecureEnclaveUnlockerMetadata struct {
|
||||||
UnlockerMetadata
|
UnlockerMetadata
|
||||||
|
|
||||||
SEKeyLabel string `json:"seKeyLabel"`
|
SEKeyLabel string `json:"seKeyLabel"`
|
||||||
SEKeyHash string `json:"seKeyHash"`
|
SEKeyHash string `json:"seKeyHash"`
|
||||||
}
|
}
|
||||||
@@ -43,6 +43,19 @@ type SecureEnclaveUnlocker struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
||||||
|
func NewSecureEnclaveUnlocker(
|
||||||
|
fs afero.Fs,
|
||||||
|
directory string,
|
||||||
|
metadata UnlockerMetadata,
|
||||||
|
) *SecureEnclaveUnlocker {
|
||||||
|
return &SecureEnclaveUnlocker{
|
||||||
|
Directory: directory,
|
||||||
|
Metadata: metadata,
|
||||||
|
fs: fs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
// GetIdentity implements Unlocker interface for SE-based unlockers.
|
||||||
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
// Decrypts the vault's long-term private key directly using the Secure Enclave.
|
||||||
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||||
@@ -58,6 +71,7 @@ func (s *SecureEnclaveUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
// Read ECIES-encrypted long-term private key from disk
|
// Read ECIES-encrypted long-term private key from disk
|
||||||
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
encryptedPath := filepath.Join(s.Directory, seLongtermFilename)
|
||||||
|
|
||||||
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
encryptedData, err := afero.ReadFile(s.fs, encryptedPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -116,17 +130,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
return s.Directory
|
return s.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID implements Unlocker interface.
|
// GetID implements Unlocker interface: the name of the unlocker's directory.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
hostname, err := os.Hostname()
|
return filepath.Base(s.Directory)
|
||||||
if err != nil {
|
|
||||||
hostname = "unknown"
|
|
||||||
}
|
|
||||||
|
|
||||||
createdAt := s.Metadata.CreatedAt
|
|
||||||
timestamp := createdAt.Format("2006-01-02.15.04")
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s-%s-%s", timestamp, hostname, seUnlockerType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove implements Unlocker interface.
|
// Remove implements Unlocker interface.
|
||||||
@@ -140,7 +146,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
|
|
||||||
if seKeyHash != "" {
|
if seKeyHash != "" {
|
||||||
Debug("Deleting SE key", "hash", seKeyHash)
|
Debug("Deleting SE key", "hash", seKeyHash)
|
||||||
if err := macse.DeleteKey(seKeyHash); err != nil {
|
|
||||||
|
err = macse.DeleteKey(seKeyHash)
|
||||||
|
if err != nil {
|
||||||
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
Debug("Failed to delete SE key", "error", err, "hash", seKeyHash)
|
||||||
|
|
||||||
return fmt.Errorf("failed to delete SE key: %w", err)
|
return fmt.Errorf("failed to delete SE key: %w", err)
|
||||||
@@ -148,7 +156,9 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
Debug("Removing SE unlocker directory", "directory", s.Directory)
|
||||||
if err := RemoveDirAtomic(s.fs, s.Directory); err != nil {
|
|
||||||
|
err = RemoveDirAtomic(s.fs, s.Directory)
|
||||||
|
if err != nil {
|
||||||
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
return fmt.Errorf("failed to remove SE unlocker directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,34 +168,24 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getSEKeyInfo reads the SE key label and hash from metadata.
|
// getSEKeyInfo reads the SE key label and hash from metadata.
|
||||||
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (label string, hash string, err error) {
|
func (s *SecureEnclaveUnlocker) getSEKeyInfo() (string, string, error) {
|
||||||
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
metadataPath := filepath.Join(s.Directory, "unlocker-metadata.json")
|
||||||
|
|
||||||
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
metadataData, err := afero.ReadFile(s.fs, metadataPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
return "", "", fmt.Errorf("failed to read SE metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var seMetadata SecureEnclaveUnlockerMetadata
|
var seMetadata SecureEnclaveUnlockerMetadata
|
||||||
if err := json.Unmarshal(metadataData, &seMetadata); err != nil {
|
|
||||||
|
err = json.Unmarshal(metadataData, &seMetadata)
|
||||||
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
return "", "", fmt.Errorf("failed to parse SE metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
return seMetadata.SEKeyLabel, seMetadata.SEKeyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSecureEnclaveUnlocker creates a new SecureEnclaveUnlocker instance.
|
|
||||||
func NewSecureEnclaveUnlocker(
|
|
||||||
fs afero.Fs,
|
|
||||||
directory string,
|
|
||||||
metadata UnlockerMetadata,
|
|
||||||
) *SecureEnclaveUnlocker {
|
|
||||||
return &SecureEnclaveUnlocker{
|
|
||||||
Directory: directory,
|
|
||||||
Metadata: metadata,
|
|
||||||
fs: fs,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
// generateSEKeyLabel generates a unique label for the SE CTK identity.
|
||||||
func generateSEKeyLabel(vaultName string) (string, error) {
|
func generateSEKeyLabel(vaultName string) (string, error) {
|
||||||
hostname, err := os.Hostname()
|
hostname, err := os.Hostname()
|
||||||
@@ -193,28 +193,29 @@ func generateSEKeyLabel(vaultName string) (string, error) {
|
|||||||
return "", fmt.Errorf("failed to get hostname: %w", err)
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
enrollmentDate := time.Now().UTC().Format("2006-01-02")
|
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
||||||
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%s.%s-%s-%s",
|
"%s.%s-%s-%s",
|
||||||
seKeyLabelPrefix,
|
seKeyLabelPrefix,
|
||||||
vaultName,
|
vaultName,
|
||||||
hostname,
|
hostname,
|
||||||
enrollmentDate,
|
enrollmentTime,
|
||||||
), nil
|
), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
|
||||||
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
// The vault's long-term private key is encrypted directly by the Secure Enclave
|
||||||
// using ECIES. No intermediate age keypair is used.
|
// using ECIES. No intermediate age keypair is used.
|
||||||
|
// The long-term key comes from mnemonic when it is not nil, else from the
|
||||||
|
// current unlocker, as getLongTermKeyForSE describes.
|
||||||
|
// The SE key is created once the long-term key is in hand and the unlocker's
|
||||||
|
// path is known, and is deleted again if a later step fails.
|
||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
stateDir string,
|
stateDir string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
if err := checkMacOSAvailable(); err != nil {
|
|
||||||
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)
|
||||||
@@ -226,17 +227,8 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
return nil, fmt.Errorf("failed to generate SE key label: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Create P-256 key in the Secure Enclave via sc_auth
|
// Step 1: Get the vault's long-term private key
|
||||||
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
|
||||||
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
|
||||||
|
|
||||||
// Step 2: Get the vault's long-term private key
|
|
||||||
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
"failed to get long-term private key: %w",
|
"failed to get long-term private key: %w",
|
||||||
@@ -245,7 +237,50 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
}
|
}
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
// Step 3: Encrypt the long-term key directly with the SE (ECIES)
|
// Step 2: Prepare the unlocker directory's path
|
||||||
|
vaultDir, err := vault.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockerDirName := "se-" + filepath.Base(seKeyLabel)
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
||||||
|
|
||||||
|
// Step 3: Create P-256 key in the Secure Enclave via sc_auth
|
||||||
|
Debug("Creating Secure Enclave key", "label", seKeyLabel)
|
||||||
|
|
||||||
|
_, seKeyHash, err := macse.CreateKey(seKeyLabel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create SE key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
|
||||||
|
|
||||||
|
// Steps 4 and 5: Write the unlocker, or delete the SE key if that fails
|
||||||
|
unlocker, err := writeSEUnlocker(fs, unlockerDir, seKeyLabel, seKeyHash,
|
||||||
|
ltPrivKeyData)
|
||||||
|
if err != nil {
|
||||||
|
deleteErr := macse.DeleteKey(seKeyHash)
|
||||||
|
if deleteErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf(
|
||||||
|
"failed to delete SE key %s: %w", seKeyLabel, deleteErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlocker, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSEUnlocker encrypts the long-term key with the SE key and writes the
|
||||||
|
// new unlocker into unlockerDir (steps 4 and 5 of
|
||||||
|
// CreateSecureEnclaveUnlocker).
|
||||||
|
func writeSEUnlocker(
|
||||||
|
fs afero.Fs, unlockerDir, seKeyLabel, seKeyHash string,
|
||||||
|
ltPrivKeyData *memguard.LockedBuffer,
|
||||||
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
|
// Step 4: Encrypt the long-term key directly with the SE (ECIES), and
|
||||||
|
// prepare the metadata
|
||||||
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
encryptedLtKey, err := macse.Encrypt(seKeyLabel, ltPrivKeyData.Bytes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -254,36 +289,11 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Create unlocker directory and write files
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDirName := fmt.Sprintf("se-%s", filepath.Base(seKeyLabel))
|
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerDirName)
|
|
||||||
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to create unlocker directory: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write SE-encrypted long-term key
|
|
||||||
ltKeyPath := filepath.Join(unlockerDir, seLongtermFilename)
|
|
||||||
if err := WriteFileAtomic(fs, ltKeyPath, encryptedLtKey); err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to write SE-encrypted long-term key: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write metadata
|
|
||||||
seMetadata := SecureEnclaveUnlockerMetadata{
|
seMetadata := SecureEnclaveUnlockerMetadata{
|
||||||
UnlockerMetadata: UnlockerMetadata{
|
UnlockerMetadata: UnlockerMetadata{
|
||||||
Type: seUnlockerType,
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
Flags: []string{seUnlockerType, "macos"},
|
Flags: []string{seUnlockerType, macOSFlag},
|
||||||
},
|
},
|
||||||
SEKeyLabel: seKeyLabel,
|
SEKeyLabel: seKeyLabel,
|
||||||
SEKeyHash: seKeyHash,
|
SEKeyHash: seKeyHash,
|
||||||
@@ -294,9 +304,12 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
// Step 5: Write the SE-encrypted long-term key, then the metadata
|
||||||
if err := WriteFileAtomic(fs, metadataPath, metadataBytes); err != nil {
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
return nil, fmt.Errorf("failed to write metadata: %w", err)
|
return writeSEUnlockerFiles(fs, dir, encryptedLtKey, metadataBytes)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SecureEnclaveUnlocker{
|
return &SecureEnclaveUnlocker{
|
||||||
@@ -306,45 +319,39 @@ func CreateSecureEnclaveUnlocker(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermKeyForSE retrieves the vault's long-term private key
|
// writeSEUnlockerFiles writes the files of a new SE unlocker into dir: the
|
||||||
// either from the mnemonic env var or by unlocking via the current unlocker.
|
// SE-encrypted long-term key, then the metadata.
|
||||||
|
func writeSEUnlockerFiles(
|
||||||
|
fs afero.Fs, dir string, encryptedLtKey, metadataBytes []byte,
|
||||||
|
) error {
|
||||||
|
err := WriteFileAtomic(fs, filepath.Join(dir, seLongtermFilename),
|
||||||
|
encryptedLtKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"failed to write SE-encrypted long-term key: %w",
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = WriteFileAtomic(fs,
|
||||||
|
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
|
||||||
|
// from mnemonic when it is not nil, else through the current unlocker, which
|
||||||
|
// is given passphrase when it is a passphrase unlocker.
|
||||||
func getLongTermKeyForSE(
|
func getLongTermKeyForSE(
|
||||||
fs afero.Fs,
|
fs afero.Fs,
|
||||||
vault VaultInterface,
|
vault VaultInterface,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
) (*memguard.LockedBuffer, error) {
|
) (*memguard.LockedBuffer, error) {
|
||||||
envMnemonic := os.Getenv(EnvMnemonic)
|
if mnemonic != nil {
|
||||||
if envMnemonic != "" {
|
return deriveLongTermPrivateKey(fs, vault, mnemonic)
|
||||||
// Read vault metadata to get the correct derivation index
|
|
||||||
vaultDir, err := vault.GetDirectory()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataPath := filepath.Join(vaultDir, "vault-metadata.json")
|
|
||||||
metadataBytes, err := afero.ReadFile(fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata VaultMetadata
|
|
||||||
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use mnemonic with the vault's actual derivation index
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(
|
|
||||||
envMnemonic,
|
|
||||||
metadata.DerivationIndex,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"failed to derive long-term key from mnemonic: %w",
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return memguard.NewBufferFromBytes([]byte(ltIdentity.String())), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUnlocker, err := vault.GetCurrentUnlocker()
|
currentUnlocker, err := vault.GetCurrentUnlocker()
|
||||||
@@ -352,6 +359,10 @@ func getLongTermKeyForSE(
|
|||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
|
||||||
|
passphraseUnlocker.Passphrase = passphrase
|
||||||
|
}
|
||||||
|
|
||||||
currentIdentity, err := currentUnlocker.GetIdentity()
|
currentIdentity, err := currentUnlocker.GetIdentity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
@@ -365,6 +376,7 @@ func getLongTermKeyForSE(
|
|||||||
currentUnlocker.GetDirectory(),
|
currentUnlocker.GetDirectory(),
|
||||||
"longterm.age",
|
"longterm.age",
|
||||||
)
|
)
|
||||||
|
|
||||||
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
encryptedLtKey, err := afero.ReadFile(fs, longtermPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf(
|
return nil, fmt.Errorf(
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ package secret
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,9 +68,9 @@ func (s *SecureEnclaveUnlocker) GetDirectory() string {
|
|||||||
return s.Directory
|
return s.Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetID returns the unlocker ID.
|
// GetID returns the unlocker ID, the name of the unlocker's directory.
|
||||||
func (s *SecureEnclaveUnlocker) GetID() string {
|
func (s *SecureEnclaveUnlocker) GetID() string {
|
||||||
return s.Metadata.CreatedAt.Format("2006-01-02.15.04") + "-" + seUnlockerType
|
return filepath.Base(s.Directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove returns an error on non-Darwin platforms.
|
// Remove returns an error on non-Darwin platforms.
|
||||||
@@ -80,6 +82,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
|
|||||||
func CreateSecureEnclaveUnlocker(
|
func CreateSecureEnclaveUnlocker(
|
||||||
_ afero.Fs,
|
_ afero.Fs,
|
||||||
_ string,
|
_ string,
|
||||||
|
_, _ *memguard.LockedBuffer,
|
||||||
) (*SecureEnclaveUnlocker, error) {
|
) (*SecureEnclaveUnlocker, error) {
|
||||||
return nil, errSENotSupported
|
return nil, errSENotSupported
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
// Test GetDirectory returns the directory we passed in
|
// Test GetDirectory returns the directory we passed in
|
||||||
assert.Equal(t, dir, unlocker.GetDirectory())
|
assert.Equal(t, dir, unlocker.GetDirectory())
|
||||||
|
|
||||||
// Test GetID returns a formatted string with the creation timestamp
|
// Test GetID returns the name of the unlocker's directory
|
||||||
expectedID := "2026-01-15.10.30-secure-enclave"
|
assert.Equal(t, "test-se-unlocker", unlocker.GetID())
|
||||||
assert.Equal(t, expectedID, unlocker.GetID())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityReturnsError(t *testing.T) {
|
||||||
@@ -78,7 +77,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
|
|||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
|
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
|
||||||
assert.Nil(t, unlocker)
|
assert.Nil(t, unlocker)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorIs(t, err, errSENotSupported)
|
require.ErrorIs(t, err, errSENotSupported)
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
// +build darwin
|
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported Secure Enclave helpers
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,12 +15,14 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker"
|
dir := "/tmp/test-se-unlocker"
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
Flags: []string{"secure-enclave", "macos"},
|
Flags: []string{seUnlockerType, "macos"},
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, dir, metadata)
|
||||||
@@ -35,9 +39,11 @@ func TestNewSecureEnclaveUnlocker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Now().UTC(),
|
CreatedAt: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,21 +54,23 @@ func TestSecureEnclaveUnlockerImplementsInterface(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIDFormat(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 3, 10, 14, 30, 0, 0, time.UTC),
|
||||||
}
|
}
|
||||||
|
|
||||||
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
unlocker := NewSecureEnclaveUnlocker(fs, "/tmp/test", metadata)
|
||||||
id := unlocker.GetID()
|
|
||||||
|
|
||||||
// ID should contain the timestamp and "secure-enclave" type
|
// The ID is the name of the unlocker's directory
|
||||||
assert.Contains(t, id, "2026-03-10.14.30")
|
assert.Equal(t, "test", unlocker.GetID())
|
||||||
assert.Contains(t, id, seUnlockerType)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateSEKeyLabel(t *testing.T) {
|
func TestGenerateSEKeyLabel(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
label, err := generateSEKeyLabel("test-vault")
|
label, err := generateSEKeyLabel("test-vault")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -72,6 +80,8 @@ func TestGenerateSEKeyLabel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
dir := "/tmp/test-se-unlocker-missing"
|
dir := "/tmp/test-se-unlocker-missing"
|
||||||
|
|
||||||
@@ -84,10 +94,12 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
|||||||
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
"seKeyLabel": "berlin.sneak.app.secret.se.test",
|
||||||
"seKeyHash": "abc123"
|
"seKeyHash": "abc123"
|
||||||
}`
|
}`
|
||||||
require.NoError(t, afero.WriteFile(fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms))
|
require.NoError(t, afero.WriteFile(
|
||||||
|
fs, dir+"/unlocker-metadata.json", []byte(metadataJSON), FilePerms,
|
||||||
|
))
|
||||||
|
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: "secure-enclave",
|
Type: seUnlockerType,
|
||||||
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
CreatedAt: time.Date(2026, 1, 15, 10, 30, 0, 0, time.UTC),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +108,10 @@ func TestSecureEnclaveUnlockerGetIdentityMissingFile(t *testing.T) {
|
|||||||
// GetIdentity should fail because the encrypted longterm key file is missing
|
// GetIdentity should fail because the encrypted longterm key file is missing
|
||||||
identity, err := unlocker.GetIdentity()
|
identity, err := unlocker.GetIdentity()
|
||||||
assert.Nil(t, identity)
|
assert.Nil(t, identity)
|
||||||
assert.Error(t, err)
|
|
||||||
assert.Contains(t, err.Error(), "failed to read SE-encrypted long-term key")
|
var cause *os.PathError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &cause)
|
||||||
|
require.ErrorIs(t, err, os.ErrNotExist)
|
||||||
|
assert.Equal(t, filepath.Join(dir, seLongtermFilename), cause.Path)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,6 @@ type Unlocker interface {
|
|||||||
GetType() string
|
GetType() string
|
||||||
GetMetadata() UnlockerMetadata
|
GetMetadata() UnlockerMetadata
|
||||||
GetDirectory() string
|
GetDirectory() string
|
||||||
GetID() string // Generate ID based on unlocker type and data
|
GetID() string // The name of the unlocker's directory, unique in its vault
|
||||||
Remove() error // Remove the unlocker and any associated resources
|
Remove() error // Remove the unlocker and any associated resources
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//go:build darwin
|
//go:build darwin
|
||||||
|
|
||||||
|
//nolint:testpackage // white-box test of unexported validateKeychainItemName
|
||||||
package secret
|
package secret
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,138 +8,46 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestValidateKeychainItemName(t *testing.T) {
|
func TestValidateKeychainItemName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
itemName string
|
itemName string
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
// Valid cases
|
// Valid cases
|
||||||
{
|
{name: "valid simple name", itemName: "my-secret-key", wantErr: false},
|
||||||
name: "valid simple name",
|
{name: "valid name with dots", itemName: "com.example.app.key", wantErr: false},
|
||||||
itemName: "my-secret-key",
|
{name: "valid name with underscores", itemName: "my_secret_key_123", wantErr: false},
|
||||||
wantErr: false,
|
{name: "valid alphanumeric", itemName: "Secret123Key", wantErr: false},
|
||||||
},
|
{name: "valid with hyphen at start", itemName: "-my-key", wantErr: false},
|
||||||
{
|
{name: "valid with dot at start", itemName: ".hidden-key", wantErr: false},
|
||||||
name: "valid name with dots",
|
|
||||||
itemName: "com.example.app.key",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid name with underscores",
|
|
||||||
itemName: "my_secret_key_123",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid alphanumeric",
|
|
||||||
itemName: "Secret123Key",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid with hyphen at start",
|
|
||||||
itemName: "-my-key",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid with dot at start",
|
|
||||||
itemName: ".hidden-key",
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Invalid cases
|
// Invalid cases
|
||||||
{
|
{name: "empty item name", itemName: "", wantErr: true},
|
||||||
name: "empty item name",
|
{name: "item name with spaces", itemName: "my secret key", wantErr: true},
|
||||||
itemName: "",
|
{name: "item name with semicolon", itemName: "key;rm -rf /", wantErr: true},
|
||||||
wantErr: true,
|
{name: "item name with pipe", itemName: "key|cat /etc/passwd", wantErr: true},
|
||||||
},
|
{name: "item name with backticks", itemName: "key`whoami`", wantErr: true},
|
||||||
{
|
{name: "item name with dollar sign", itemName: "key$(whoami)", wantErr: true},
|
||||||
name: "item name with spaces",
|
{name: "item name with quotes", itemName: "key\"name", wantErr: true},
|
||||||
itemName: "my secret key",
|
{name: "item name with single quotes", itemName: "key'name", wantErr: true},
|
||||||
wantErr: true,
|
{name: "item name with backslash", itemName: "key\\name", wantErr: true},
|
||||||
},
|
{name: "item name with newline", itemName: "key\nname", wantErr: true},
|
||||||
{
|
{name: "item name with carriage return", itemName: "key\rname", wantErr: true},
|
||||||
name: "item name with semicolon",
|
{name: "item name with ampersand", itemName: "key&echo test", wantErr: true},
|
||||||
itemName: "key;rm -rf /",
|
{name: "item name with redirect", itemName: "key>/tmp/test", wantErr: true},
|
||||||
wantErr: true,
|
{name: "item name with null byte", itemName: "key\x00name", wantErr: true},
|
||||||
},
|
{name: "item name with parentheses", itemName: "key(test)", wantErr: true},
|
||||||
{
|
{name: "item name with brackets", itemName: "key[test]", wantErr: true},
|
||||||
name: "item name with pipe",
|
{name: "item name with asterisk", itemName: "key*", wantErr: true},
|
||||||
itemName: "key|cat /etc/passwd",
|
{name: "item name with question mark", itemName: "key?", wantErr: true},
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with backticks",
|
|
||||||
itemName: "key`whoami`",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with dollar sign",
|
|
||||||
itemName: "key$(whoami)",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with quotes",
|
|
||||||
itemName: "key\"name",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with single quotes",
|
|
||||||
itemName: "key'name",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with backslash",
|
|
||||||
itemName: "key\\name",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with newline",
|
|
||||||
itemName: "key\nname",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with carriage return",
|
|
||||||
itemName: "key\rname",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with ampersand",
|
|
||||||
itemName: "key&echo test",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with redirect",
|
|
||||||
itemName: "key>/tmp/test",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with null byte",
|
|
||||||
itemName: "key\x00name",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with parentheses",
|
|
||||||
itemName: "key(test)",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with brackets",
|
|
||||||
itemName: "key[test]",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with asterisk",
|
|
||||||
itemName: "key*",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "item name with question mark",
|
|
||||||
itemName: "key?",
|
|
||||||
wantErr: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
err := validateKeychainItemName(tt.itemName)
|
err := validateKeychainItemName(tt.itemName)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("validateKeychainItemName() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
|||||||
+13
-10
@@ -22,10 +22,10 @@ const (
|
|||||||
maxVersionsPerDay = 999
|
maxVersionsPerDay = 999
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
||||||
errMaxVersionsPerDay = errors.New("exceeded maximum versions per day (999)")
|
|
||||||
errNilValueBuffer = errors.New("value buffer is nil")
|
// ErrNilValueBuffer is returned when a secret's value is given as 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,9 +175,7 @@ func (sv *Version) Save(value *memguard.LockedBuffer) error {
|
|||||||
return fmt.Errorf("failed to generate version keypair: %w", err)
|
return fmt.Errorf("failed to generate version keypair: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store private key in memguard buffer immediately
|
versionPrivateKeyBuffer := IdentityToLockedBuffer(versionIdentity)
|
||||||
versionPrivateKeyBuffer := memguard.NewBufferFromBytes(
|
|
||||||
[]byte(versionIdentity.String()))
|
|
||||||
defer versionPrivateKeyBuffer.Destroy()
|
defer versionPrivateKeyBuffer.Destroy()
|
||||||
|
|
||||||
DebugWith("Generated version keypair",
|
DebugWith("Generated version keypair",
|
||||||
@@ -559,13 +557,18 @@ func VersionExists(fs afero.Fs, secretDir string, version string) (bool, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetCurrentVersion returns the version that the "current" file points to
|
// GetCurrentVersion returns the version that the "current" file points to
|
||||||
// The file contains just the version name (e.g., "20231215.001")
|
// The file contains just the version name (e.g., "20231215.001"). If it
|
||||||
|
// cannot be read, the error says how to make a version current again: the
|
||||||
|
// versions themselves are not in the file.
|
||||||
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
func GetCurrentVersion(fs afero.Fs, secretDir string) (string, error) {
|
||||||
currentPath := filepath.Join(secretDir, "current")
|
currentPath := filepath.Join(secretDir, "current")
|
||||||
|
|
||||||
fileData, err := afero.ReadFile(fs, currentPath)
|
fileData, err := afero.ReadFile(fs, currentPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to read current version file: %w", err)
|
return "", fmt.Errorf("failed to read current version file: %w; "+
|
||||||
|
"this file only names the current version: 'secret version list' "+
|
||||||
|
"lists the secret's versions, and 'secret version promote' makes "+
|
||||||
|
"one of them current", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
version := strings.TrimSpace(string(fileData))
|
version := strings.TrimSpace(string(fileData))
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -36,7 +36,6 @@ package secret_test
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -87,6 +86,14 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return nil, errNotImplementedInMock
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
|
return nil, errNotImplementedInMock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVersionVault) SetMnemonic(_ *memguard.LockedBuffer) {}
|
||||||
|
|
||||||
|
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*secret.PassphraseUnlocker, error) {
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
@@ -119,27 +126,6 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,24 @@ var (
|
|||||||
"derived public key does not match vault: mnemonic may be incorrect",
|
"derived public key does not match vault: mnemonic may be incorrect",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidVaultName indicates a vault name that does not match the
|
// ErrInvalidVaultName indicates a vault name that breaks the naming
|
||||||
// allowed pattern [a-z0-9.\-_]+. Composed as
|
// rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
|
||||||
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
|
// empty, "." or "..". Composed by ValidateVaultName as
|
||||||
|
// "invalid vault name '<name>': <the rule>".
|
||||||
ErrInvalidVaultName = errors.New("invalid vault name")
|
ErrInvalidVaultName = errors.New("invalid vault name")
|
||||||
|
|
||||||
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
||||||
// as "vault <name> does not exist".
|
// as "vault <name> does not exist".
|
||||||
ErrVaultNotFound = errors.New("does not exist")
|
ErrVaultNotFound = errors.New("does not exist")
|
||||||
|
|
||||||
// ErrNilValueBuffer indicates a nil value buffer was supplied.
|
// ErrVaultExists indicates that a vault to be created already exists.
|
||||||
ErrNilValueBuffer = errors.New("value buffer is nil")
|
// Composed as "vault <name> already exists".
|
||||||
|
ErrVaultExists = errors.New("already exists")
|
||||||
|
|
||||||
|
// ErrUnlockerWithoutMnemonic indicates that CreateVault was given a
|
||||||
|
// passphrase for an unlocker but no mnemonic to derive the long-term key
|
||||||
|
// it unlocks. Composed as "vault <name> needs a mnemonic for an unlocker".
|
||||||
|
ErrUnlockerWithoutMnemonic = errors.New("needs a mnemonic for an unlocker")
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package vault_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
@@ -40,46 +41,49 @@ func deriveVaultIdentity(
|
|||||||
return ltIdentity
|
return ltIdentity
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
|
||||||
func TestVaultWithRealFilesystem(t *testing.T) {
|
func TestVaultWithRealFilesystem(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create a temporary directory for our tests
|
// Create a temporary directory for our tests
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
// Use the real filesystem
|
// Use the real filesystem
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Set test environment variables
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
// Test currentvault file handling (plain file with relative path)
|
// Test currentvault file handling (plain file with relative path)
|
||||||
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
t.Run("CurrentVaultFileHandling", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
testCurrentVaultFileHandling(t, fs, tempDir)
|
testCurrentVaultFileHandling(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test secret operations with deeply nested paths
|
// Test secret operations with deeply nested paths
|
||||||
t.Run("DeepPathSecrets", func(t *testing.T) {
|
t.Run("DeepPathSecrets", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
testDeepPathSecrets(t, fs, tempDir)
|
testDeepPathSecrets(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test key caching in GetOrDeriveLongTermKey
|
// Test key caching in GetOrDeriveLongTermKey
|
||||||
t.Run("KeyCaching", func(t *testing.T) {
|
t.Run("KeyCaching", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
testKeyCaching(t, fs, tempDir)
|
testKeyCaching(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test vault name validation
|
// Test vault name validation
|
||||||
t.Run("VaultNameValidation", func(t *testing.T) {
|
t.Run("VaultNameValidation", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
testVaultNameValidation(t, fs, tempDir)
|
testVaultNameValidation(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test multiple vaults and switching between them
|
// Test multiple vaults and switching between them
|
||||||
t.Run("MultipleVaults", func(t *testing.T) {
|
t.Run("MultipleVaults", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
testMultipleVaults(t, fs, tempDir)
|
testMultipleVaults(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Test adding a secret in one vault and verifying it's not visible in
|
// Test adding a secret in one vault and verifying it's not visible in
|
||||||
// another
|
// another
|
||||||
t.Run("VaultIsolation", func(t *testing.T) {
|
t.Run("VaultIsolation", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
testVaultIsolation(t, fs, tempDir)
|
testVaultIsolation(t, fs, tempDir)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -95,7 +99,8 @@ func testCurrentVaultFileHandling(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault
|
// Create a test vault
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -140,9 +145,10 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault - CreateVault now handles public key when
|
// Create a test vault - CreateVault writes the public key derived from
|
||||||
// mnemonic is in env
|
// the mnemonic
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -197,10 +203,11 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
t.Fatalf("Failed to retrieve deep path secret: %v", err)
|
||||||
}
|
}
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
if string(retrievedValue) != string(expectedValue) {
|
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
||||||
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
|
||||||
string(expectedValue), string(retrievedValue))
|
expectedValue, retrievedValue.Bytes())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,9 +221,10 @@ func testKeyCaching(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a test vault - CreateVault now handles public key when
|
// Create a test vault - CreateVault writes the public key derived from
|
||||||
// mnemonic is in env
|
// the mnemonic
|
||||||
vlt, err := vault.CreateVault(fs, stateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, stateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -317,7 +325,7 @@ func testVaultNameValidation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, name := range validNames {
|
for _, name := range validNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name)
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
t.Errorf("Failed to create vault with valid name %q: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -333,10 +341,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)
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
if err == nil {
|
if !errors.Is(err, vault.ErrInvalidVaultName) {
|
||||||
t.Errorf("Expected error creating vault with invalid name %q, "+
|
t.Errorf("Expected ErrInvalidVaultName creating vault with "+
|
||||||
"but got none", name)
|
"invalid name %q, got %v", name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,7 +362,7 @@ func testMultipleVaults(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
// Create three vaults
|
// Create three vaults
|
||||||
vaultNames := []string{"vault1", "vault2", "vault3"}
|
vaultNames := []string{"vault1", "vault2", "vault3"}
|
||||||
for _, name := range vaultNames {
|
for _, name := range vaultNames {
|
||||||
_, err := vault.CreateVault(fs, stateDir, name)
|
_, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault %s: %v", name, err)
|
t.Fatalf("Failed to create vault %s: %v", name, err)
|
||||||
}
|
}
|
||||||
@@ -402,14 +410,14 @@ func testVaultIsolation(t *testing.T, fs afero.Fs, tempDir string) {
|
|||||||
t.Fatalf("Failed to create state dir: %v", err)
|
t.Fatalf("Failed to create state dir: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create two vaults - CreateVault now handles public key when mnemonic
|
// Create two vaults - CreateVault writes the public key derived from
|
||||||
// is in env
|
// the mnemonic
|
||||||
vault1, err := vault.CreateVault(fs, stateDir, "vault1")
|
vault1, err := vault.CreateVault(fs, stateDir, "vault1", testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault1: %v", err)
|
t.Fatalf("Failed to create vault1: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2, err := vault.CreateVault(fs, stateDir, "vault2")
|
vault2, err := vault.CreateVault(fs, stateDir, "vault2", testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault2: %v", err)
|
t.Fatalf("Failed to create vault2: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,15 +44,12 @@ var errUnexpectedValue = errors.New("unexpected value")
|
|||||||
|
|
||||||
// TestVersionIntegrationWorkflow tests the complete version workflow
|
// TestVersionIntegrationWorkflow tests the complete version workflow
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // t.Setenv forbids parallel subtests
|
//nolint:paralleltest // the subtests are steps that build on each other
|
||||||
func TestVersionIntegrationWorkflow(t *testing.T) {
|
func TestVersionIntegrationWorkflow(t *testing.T) {
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set mnemonic for testing
|
// Create vault without a long-term key, which is set up below
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vault, err := CreateVault(fs, testStateDir, "test")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -119,7 +116,10 @@ func testCreateInitialVersion(
|
|||||||
// Verify secret can be retrieved
|
// Verify secret can be retrieved
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-1-data"), value)
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
||||||
|
|
||||||
// Verify version directory structure
|
// Verify version directory structure
|
||||||
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
|
||||||
@@ -166,7 +166,10 @@ func testCreateSecondVersion(
|
|||||||
// Verify new value is current
|
// Verify new value is current
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2-data"), value)
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value.Bytes())
|
||||||
|
|
||||||
// Verify we now have two versions
|
// Verify we now have two versions
|
||||||
versions, err = secret.ListVersions(fs, secretDir)
|
versions, err = secret.ListVersions(fs, secretDir)
|
||||||
@@ -209,7 +212,10 @@ func testCreateThirdVersion(
|
|||||||
// Current should be version-3
|
// Current should be version-3
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-3-data"), value)
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRetrieveSpecificVersions(
|
func testRetrieveSpecificVersions(
|
||||||
@@ -225,15 +231,24 @@ func testRetrieveSpecificVersions(
|
|||||||
// Get each version by its name
|
// Get each version by its name
|
||||||
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-1-data"), value1)
|
|
||||||
|
defer value1.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
|
||||||
|
|
||||||
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2-data"), value2)
|
|
||||||
|
defer value2.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
|
||||||
|
|
||||||
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-3-data"), value3)
|
|
||||||
|
defer value3.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
|
||||||
|
|
||||||
// An empty version is not one of the versions; GetSecret gets the
|
// An empty version is not one of the versions; GetSecret gets the
|
||||||
// current one
|
// current one
|
||||||
@@ -259,7 +274,10 @@ func testPromoteOldVersion(
|
|||||||
// Verify current now returns the old version's value
|
// Verify current now returns the old version's value
|
||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-1-data"), value)
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-1-data"), value.Bytes())
|
||||||
|
|
||||||
// Verify the version metadata hasn't changed
|
// Verify the version metadata hasn't changed
|
||||||
// (promoting shouldn't modify timestamps)
|
// (promoting shouldn't modify timestamps)
|
||||||
@@ -302,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
|
// Should fail to create 1000th version. The error is unexported in
|
||||||
|
// 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) {
|
||||||
@@ -313,26 +331,24 @@ 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.Error(t, err)
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||||
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.Error(t, err)
|
require.ErrorIs(t, err, ErrSecretNotFound)
|
||||||
|
|
||||||
// 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.Error(t, err)
|
require.ErrorIs(t, err, ErrSecretExists)
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionConcurrency tests concurrent version operations
|
// TestVersionConcurrency tests concurrent version operations
|
||||||
//
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVersionConcurrency(t *testing.T) {
|
func TestVersionConcurrency(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
@@ -345,6 +361,8 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
|
|
||||||
// Test concurrent reads
|
// Test concurrent reads
|
||||||
t.Run("concurrent_reads", func(t *testing.T) {
|
t.Run("concurrent_reads", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
done := make(chan bool, 10)
|
done := make(chan bool, 10)
|
||||||
errCh := make(chan error, 10)
|
errCh := make(chan error, 10)
|
||||||
|
|
||||||
@@ -353,8 +371,13 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
value, err := vault.GetSecret(secretName)
|
value, err := vault.GetSecret(secretName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errCh <- err
|
errCh <- err
|
||||||
} else if string(value) != "initial" {
|
} else {
|
||||||
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
|
if value.String() != "initial" {
|
||||||
|
errCh <- fmt.Errorf("%w: %s",
|
||||||
|
errUnexpectedValue, value.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
value.Destroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
done <- true
|
done <- true
|
||||||
@@ -377,9 +400,9 @@ func TestVersionConcurrency(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestVersionCompatibility tests that old secrets without versions still work
|
// TestVersionCompatibility tests that old secrets without versions still work
|
||||||
//
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVersionCompatibility(t *testing.T) {
|
func TestVersionCompatibility(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Set up vault
|
// Set up vault
|
||||||
|
|||||||
+95
-2
@@ -1,6 +1,7 @@
|
|||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -14,6 +15,11 @@ import (
|
|||||||
// lockFileName is the file in the state directory that LockStateDir locks.
|
// lockFileName is the file in the state directory that LockStateDir locks.
|
||||||
const lockFileName = "lock"
|
const lockFileName = "lock"
|
||||||
|
|
||||||
|
// finishedMark is what the lock file holds once the command that last held
|
||||||
|
// the lock has released it. A command killed while holding it leaves the
|
||||||
|
// file empty.
|
||||||
|
const finishedMark = "finished\n"
|
||||||
|
|
||||||
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
// memFsLock stands in for the lock file on the in-memory filesystem, which
|
||||||
// has no file locks. Every in-memory filesystem in the process shares it.
|
// has no file locks. Every in-memory filesystem in the process shares it.
|
||||||
//
|
//
|
||||||
@@ -25,6 +31,12 @@ var memFsLock sync.Mutex
|
|||||||
// it. While one command holds it, the next one waits here. Reads take no
|
// it. While one command holds it, the next one waits here. Reads take no
|
||||||
// lock: each file or directory a command changes is replaced in a single
|
// lock: each file or directory a command changes is replaced in a single
|
||||||
// rename, so a reader finds it as it was before or after, never half-made.
|
// rename, so a reader finds it as it was before or after, never half-made.
|
||||||
|
// Once it holds the lock, it empties the lock file, and the function it
|
||||||
|
// returns writes finishedMark there just before releasing the lock, so a
|
||||||
|
// command killed while holding the lock leaves the mark missing. Finding it
|
||||||
|
// missing, LockStateDir first deletes the temporary files and directories
|
||||||
|
// such a command may have left, since no command still using them can be
|
||||||
|
// running. After a command that finished, it searches nothing.
|
||||||
//
|
//
|
||||||
// On the real filesystem the lock is flock(2) on the file "lock" in
|
// On the real filesystem the lock is flock(2) on the file "lock" in
|
||||||
// stateDir, which the kernel releases when the process dies, so a killed
|
// stateDir, which the kernel releases when the process dies, so a killed
|
||||||
@@ -32,16 +44,97 @@ var memFsLock sync.Mutex
|
|||||||
// use has no file locks, so a process-wide mutex stands in for flock there.
|
// use has no file locks, so a process-wide mutex stands in for flock there.
|
||||||
// Any other filesystem is refused rather than left unlocked.
|
// Any other filesystem is refused rather than left unlocked.
|
||||||
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
func LockStateDir(fs afero.Fs, stateDir string) (func(), error) {
|
||||||
|
var release func()
|
||||||
|
|
||||||
switch fs.(type) {
|
switch fs.(type) {
|
||||||
case *afero.OsFs:
|
case *afero.OsFs:
|
||||||
return flockStateDir(stateDir)
|
var err error
|
||||||
|
|
||||||
|
release, err = flockStateDir(stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
case *afero.MemMapFs:
|
case *afero.MemMapFs:
|
||||||
memFsLock.Lock()
|
memFsLock.Lock()
|
||||||
|
|
||||||
return memFsLock.Unlock, nil
|
release = memFsLock.Unlock
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
return nil, fmt.Errorf("%w %T", ErrNoLockForFilesystem, fs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The lock file is written in place, never replaced: a command waiting
|
||||||
|
// for flock on the old file would then take a lock nobody else checks.
|
||||||
|
lockPath := filepath.Join(stateDir, lockFileName)
|
||||||
|
|
||||||
|
mark, err := afero.ReadFile(fs, lockPath)
|
||||||
|
if err != nil || string(mark) != finishedMark {
|
||||||
|
removeLeftovers(fs, stateDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = afero.WriteFile(fs, lockPath, nil, secret.FilePerms)
|
||||||
|
if err != nil {
|
||||||
|
release()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("failed to empty lock file %s: %w", lockPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
// If this fails, the next command searches when it need not.
|
||||||
|
_ = afero.WriteFile(fs, lockPath, []byte(finishedMark), secret.FilePerms)
|
||||||
|
|
||||||
|
release()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeLeftovers deletes the temporary files and directories that commands
|
||||||
|
// killed part-way left in each directory where secret.WriteFileAtomic and
|
||||||
|
// secret.TempDirFor make them: the state directory, each vault, each secret
|
||||||
|
// and each version. Unlocker directories are written whole by
|
||||||
|
// secret.WriteDir and never changed after, so they hold none. A failure is
|
||||||
|
// only warned about, and the command goes on.
|
||||||
|
func removeLeftovers(fs afero.Fs, stateDir string) {
|
||||||
|
dirs := []string{stateDir}
|
||||||
|
|
||||||
|
for _, vaultDir := range subdirs(fs, filepath.Join(stateDir, "vaults.d")) {
|
||||||
|
dirs = append(dirs, vaultDir)
|
||||||
|
|
||||||
|
for _, secretDir := range subdirs(fs, filepath.Join(vaultDir, "secrets.d")) {
|
||||||
|
dirs = append(dirs, secretDir)
|
||||||
|
dirs = append(dirs, subdirs(fs, filepath.Join(secretDir, "versions"))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range dirs {
|
||||||
|
err := secret.RemoveLeftovers(fs, dir)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Failed to remove what an interrupted command left",
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// subdirs returns the directories in dir: none if dir does not exist, and
|
||||||
|
// none, with a warning, if it cannot be read.
|
||||||
|
func subdirs(fs afero.Fs, dir string) []string {
|
||||||
|
entries, err := afero.ReadDir(fs, dir)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
secret.Warn("Failed to look for what an interrupted command left",
|
||||||
|
"directory", dir, "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
dirs = append(dirs, filepath.Join(dir, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs
|
||||||
}
|
}
|
||||||
|
|
||||||
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
// flockStateDir takes flock(2) on the lock file in stateDir, creating the
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -121,6 +123,51 @@ func TestLockStateDirFreeAfterPanic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLockStateDirRemovesLeftoversOnlyAfterKill checks that taking the lock
|
||||||
|
// deletes a temporary directory a killed command left only when the last
|
||||||
|
// holder of the lock did not release it. A holder killed while it holds the
|
||||||
|
// lock leaves the lock file as it is at that moment.
|
||||||
|
func TestLockStateDirRemovesLeftoversOnlyAfterKill(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, lfs := range lockFilesystems(t) {
|
||||||
|
t.Run(lfs.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
lockFile := filepath.Join(lfs.stateDir, "lock")
|
||||||
|
leftover := filepath.Join(lfs.stateDir, ".tmp-1")
|
||||||
|
|
||||||
|
release, err := vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
whileHeld, err := afero.ReadFile(lfs.fs, lockFile)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
require.NoError(t, lfs.fs.MkdirAll(leftover, secret.DirPerms))
|
||||||
|
|
||||||
|
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(lfs.fs, leftover)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists, "searched after a holder that finished")
|
||||||
|
|
||||||
|
require.NoError(t, afero.WriteFile(lfs.fs, lockFile, whileHeld,
|
||||||
|
secret.FilePerms))
|
||||||
|
|
||||||
|
release, err = vault.LockStateDir(lfs.fs, lfs.stateDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
release()
|
||||||
|
|
||||||
|
exists, err = afero.DirExists(lfs.fs, leftover)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists, "not searched after a holder that was killed")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
// TestLockStateDirRefusesOtherFilesystems checks that a filesystem with no
|
||||||
// lock implementation is refused instead of being used unlocked.
|
// lock implementation is refused instead of being used unlocked.
|
||||||
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
func TestLockStateDirRefusesOtherFilesystems(t *testing.T) {
|
||||||
|
|||||||
+127
-73
@@ -3,14 +3,15 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,10 +25,12 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
// isValidVaultName reports whether name is a valid vault name: only
|
||||||
// Note: We don't allow slashes in vault names unlike secret names
|
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
|
||||||
|
// "..". With no path separator allowed, a vault is always one directory
|
||||||
|
// directly under vaults.d.
|
||||||
func isValidVaultName(name string) bool {
|
func isValidVaultName(name string) bool {
|
||||||
if name == "" {
|
if name == "" || name == "." || name == ".." {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +39,21 @@ func isValidVaultName(name string) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
|
||||||
|
// is not a valid vault name. Call it on the name exactly as the user gave it,
|
||||||
|
// before building any path from it.
|
||||||
|
func ValidateVaultName(name string) error {
|
||||||
|
if !isValidVaultName(name) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
|
||||||
|
"are allowed, and a name must not be empty, '.' or '..'",
|
||||||
|
ErrInvalidVaultName, name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||||
// The file contains just the vault name (e.g., "default")
|
// The file contains just the vault name (e.g., "default")
|
||||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||||
@@ -135,33 +153,34 @@ func ListVaults(fs afero.Fs, stateDir string) ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// processMnemonicForVault handles mnemonic processing for vault creation.
|
// processMnemonicForVault handles mnemonic processing for vault creation.
|
||||||
// It returns the derivation index, public key hash, and family hash.
|
// It returns the long-term key, nil when there is no mnemonic, and the
|
||||||
|
// derivation index, public key hash, and family hash.
|
||||||
func processMnemonicForVault(
|
func processMnemonicForVault(
|
||||||
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
fs afero.Fs, stateDir, vaultDir, vaultName string,
|
||||||
) (uint32, string, string, error) {
|
mnemonicBuffer *memguard.LockedBuffer,
|
||||||
// Check if mnemonic is available in environment
|
) (*age.X25519Identity, uint32, string, string, error) {
|
||||||
mnemonic := os.Getenv(secret.EnvMnemonic)
|
if mnemonicBuffer == nil {
|
||||||
|
secret.Debug("No mnemonic given, vault created without long-term key",
|
||||||
if mnemonic == "" {
|
|
||||||
secret.Debug("No mnemonic in environment, vault created without long-term key",
|
|
||||||
"vault", vaultName)
|
"vault", vaultName)
|
||||||
// Use 0 for derivation index when no mnemonic is provided
|
// Use 0 for derivation index when no mnemonic is provided
|
||||||
return 0, "", "", nil
|
return nil, 0, "", "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Mnemonic found in environment, deriving long-term key",
|
mnemonic := mnemonicBuffer.String()
|
||||||
"vault", vaultName)
|
|
||||||
|
secret.Debug("Mnemonic given, deriving long-term key", "vault", vaultName)
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
// Get the next available derivation index for this mnemonic
|
||||||
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
derivationIndex, err := GetNextDerivationIndex(fs, stateDir, mnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to get next derivation index: %w", err)
|
return nil, 0, "", "",
|
||||||
|
fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Derive the long-term key using the actual derivation index
|
// Derive the long-term key using the actual derivation index
|
||||||
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
return nil, 0, "", "", fmt.Errorf("failed to derive long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the public key
|
// Write the public key
|
||||||
@@ -171,7 +190,8 @@ func processMnemonicForVault(
|
|||||||
|
|
||||||
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
err = secret.WriteFileAtomic(fs, ltPubKeyPath, []byte(ltPubKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to write long-term public key: %w", err)
|
return nil, 0, "", "",
|
||||||
|
fmt.Errorf("failed to write long-term public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
secret.Debug("Wrote long-term public key", "path", ltPubKeyPath)
|
||||||
@@ -183,76 +203,69 @@ func processMnemonicForVault(
|
|||||||
// This is used to identify which vaults belong to the same mnemonic family
|
// This is used to identify which vaults belong to the same mnemonic family
|
||||||
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, "", "", fmt.Errorf("failed to derive identity for index 0: %w", err)
|
return nil, 0, "", "",
|
||||||
|
fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
familyHash := ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
return derivationIndex, publicKeyHash, familyHash, nil
|
return ltIdentity, derivationIndex, publicKeyHash, familyHash, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVault creates a new vault
|
// CreateVault creates a new vault and selects it as the current vault. When
|
||||||
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
||||||
|
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
||||||
|
// long-term key until one is imported. When passphrase is not nil, the vault
|
||||||
|
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
||||||
|
// needs a mnemonic. It refuses a vault that already exists before writing
|
||||||
|
// anything: creating it again would replace its keys, and its secrets could
|
||||||
|
// no longer be decrypted. The commands that call it hold the state directory
|
||||||
|
// lock, so no other command can create the vault between the check and the
|
||||||
|
// writes.
|
||||||
|
//
|
||||||
|
// The vault is written whole into a temporary directory, which is renamed
|
||||||
|
// into vaults.d only once complete, and only then selected: a crash at any
|
||||||
|
// point leaves either no vault or a complete one. The next command that
|
||||||
|
// takes the lock deletes what the crash left under a temporary name.
|
||||||
|
func CreateVault(
|
||||||
|
fs afero.Fs, stateDir string, name string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
|
) (*Vault, error) {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
// Validate vault name
|
err := ValidateVaultName(name)
|
||||||
if !isValidVaultName(name) {
|
if err != nil {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, err
|
||||||
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
||||||
ErrInvalidVaultName, name,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|
||||||
// Create vault directory structure
|
|
||||||
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
vaultDir := filepath.Join(stateDir, "vaults.d", name)
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check if vault exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return nil, fmt.Errorf("vault %s %w", name, ErrVaultExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
if passphrase != nil && mnemonic == nil {
|
||||||
|
return nil, fmt.Errorf("vault %s %w", name, ErrUnlockerWithoutMnemonic)
|
||||||
|
}
|
||||||
|
|
||||||
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
secret.Debug("Creating vault directory structure", "vault_dir", vaultDir)
|
||||||
|
|
||||||
// Create main vault directory
|
err = secret.WriteDir(fs, vaultDir, func(dir string) error {
|
||||||
err := fs.MkdirAll(vaultDir, secret.DirPerms)
|
return writeVaultFiles(fs, stateDir, dir, name, mnemonic, passphrase)
|
||||||
if err != nil {
|
})
|
||||||
return nil, fmt.Errorf("failed to create vault directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create secrets directory
|
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
|
||||||
|
|
||||||
err = fs.MkdirAll(secretsDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create secrets directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create unlockers directory
|
|
||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
|
||||||
|
|
||||||
err = fs.MkdirAll(unlockersDir, secret.DirPerms)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create unlockers directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process mnemonic if available
|
|
||||||
derivationIndex, publicKeyHash, familyHash, err := processMnemonicForVault(
|
|
||||||
fs, stateDir, vaultDir, name)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save vault metadata
|
|
||||||
metadata := &Metadata{
|
|
||||||
CreatedAt: time.Now(),
|
|
||||||
DerivationIndex: derivationIndex,
|
|
||||||
PublicKeyHash: publicKeyHash,
|
|
||||||
MnemonicFamilyHash: familyHash,
|
|
||||||
}
|
|
||||||
|
|
||||||
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to save vault metadata: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Select the newly created vault as current
|
// Select the newly created vault as current
|
||||||
secret.Debug("Selecting newly created vault as current", "name", name)
|
secret.Debug("Selecting newly created vault as current", "name", name)
|
||||||
|
|
||||||
@@ -264,21 +277,62 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
// Create and return the vault
|
// Create and return the vault
|
||||||
secret.Debug("Successfully created vault", "name", name)
|
secret.Debug("Successfully created vault", "name", name)
|
||||||
|
|
||||||
return NewVault(fs, stateDir, name), nil
|
vlt := NewVault(fs, stateDir, name)
|
||||||
|
vlt.Mnemonic = mnemonic
|
||||||
|
|
||||||
|
return vlt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeVaultFiles writes the files of the new vault name into vaultDir: its
|
||||||
|
// secrets and unlockers directories, its long-term public key and metadata,
|
||||||
|
// and, when passphrase is not nil, a passphrase unlocker as its current one.
|
||||||
|
func writeVaultFiles(
|
||||||
|
fs afero.Fs, stateDir, vaultDir, name string,
|
||||||
|
mnemonic, passphrase *memguard.LockedBuffer,
|
||||||
|
) error {
|
||||||
|
for _, subdir := range []string{"secrets.d", "unlockers.d"} {
|
||||||
|
err := fs.MkdirAll(filepath.Join(vaultDir, subdir), secret.DirPerms)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create %s directory: %w", subdir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ltIdentity, derivationIndex, publicKeyHash, familyHash, err :=
|
||||||
|
processMnemonicForVault(fs, stateDir, vaultDir, name, mnemonic)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := &Metadata{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
DerivationIndex: derivationIndex,
|
||||||
|
PublicKeyHash: publicKeyHash,
|
||||||
|
MnemonicFamilyHash: familyHash,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = SaveVaultMetadata(fs, vaultDir, metadata)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if passphrase == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = writePassphraseUnlocker(fs, vaultDir, ltIdentity, passphrase)
|
||||||
|
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SelectVault selects the given vault as the current vault
|
// SelectVault selects the given vault as the current vault
|
||||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
// Validate vault name
|
err := ValidateVaultName(name)
|
||||||
if !isValidVaultName(name) {
|
if err != nil {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return fmt.Errorf(
|
return err
|
||||||
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
||||||
ErrInvalidVaultName, name,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|||||||
@@ -297,14 +297,14 @@ func TestSampleHashCalculation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkflowMismatch(t *testing.T) {
|
func TestWorkflowMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
// Create a temporary directory for testing
|
// Create a temporary directory for testing
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
fs := afero.NewOsFs()
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
// Test Case 1: Create vault WITH mnemonic (like init command)
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, tempDir, "default")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
@@ -321,19 +321,15 @@ func TestWorkflowMismatch(t *testing.T) {
|
|||||||
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||||
|
|
||||||
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||||
t.Setenv("SB_SECRET_MNEMONIC", "")
|
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
|
||||||
|
|
||||||
_, err = vault.CreateVault(fs, tempDir, "work")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
||||||
|
|
||||||
// Simulate the vault import process
|
// Simulate the vault import process: get the next available derivation
|
||||||
t.Setenv("SB_SECRET_MNEMONIC", testMnemonic)
|
// index for this mnemonic
|
||||||
|
|
||||||
// Get the next available derivation index for this mnemonic
|
|
||||||
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get next derivation index: %v", err)
|
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package vault_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
|
||||||
"git.eeqj.de/sneak/secret/internal/vault"
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -13,15 +12,13 @@ import (
|
|||||||
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
// TestGetSecretVersionRejectsPathTraversal verifies that GetSecretVersion
|
||||||
// validates the secret name and rejects path traversal attempts.
|
// validates the secret name and rejects path traversal attempts.
|
||||||
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
// This is a regression test for https://git.eeqj.de/sneak/secret/issues/13
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
|
||||||
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Add a legitimate secret so the vault is set up
|
// Add a legitimate secret so the vault is set up
|
||||||
@@ -41,11 +38,11 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
_, err := vlt.GetSecretVersion(name, "")
|
_, err := vlt.GetSecretVersion(name, "")
|
||||||
require.Error(t, err,
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
|
||||||
"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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,30 +50,27 @@ func TestGetSecretVersionRejectsPathTraversal(t *testing.T) {
|
|||||||
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
// TestGetSecretRejectsPathTraversal verifies GetSecret (which calls
|
||||||
// GetSecretVersion) also rejects path traversal names.
|
// GetSecretVersion) also rejects path traversal names.
|
||||||
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretRejectsPathTraversal(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
_, err = vlt.GetSecret("../../../etc/passwd")
|
_, err = vlt.GetSecret("../../../etc/passwd")
|
||||||
require.Error(t, err)
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName)
|
||||||
require.Contains(t, err.Error(), "invalid secret name")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
// TestGetSecretObjectRejectsPathTraversal verifies GetSecretObject
|
||||||
// also validates names and rejects path traversal attempts.
|
// also validates names and rejects path traversal attempts.
|
||||||
//
|
|
||||||
//nolint:paralleltest // t.Setenv in parent forbids parallel subtests
|
|
||||||
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Parallel()
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
maliciousNames := []string{
|
maliciousNames := []string{
|
||||||
@@ -87,9 +81,11 @@ func TestGetSecretObjectRejectsPathTraversal(t *testing.T) {
|
|||||||
|
|
||||||
for _, name := range maliciousNames {
|
for _, name := range maliciousNames {
|
||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
_, err := vlt.GetSecretObject(name)
|
_, err := vlt.GetSecretObject(name)
|
||||||
require.Error(t, err, "GetSecretObject should reject: %s", name)
|
require.ErrorIs(t, err, vault.ErrInvalidSecretName,
|
||||||
require.Contains(t, err.Error(), "invalid secret name")
|
"GetSecretObject should reject: %s", name)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-18
@@ -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 ErrNilValueBuffer
|
return secret.ErrNilValueBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Adding secret to vault",
|
secret.DebugWith("Adding secret to vault",
|
||||||
@@ -301,8 +301,9 @@ func updateVersionMetadata(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSecret retrieves the current version of a secret from this vault
|
// GetSecret retrieves the current version of a secret from this vault.
|
||||||
func (v *Vault) GetSecret(name string) ([]byte, error) {
|
// The caller must destroy the returned buffer.
|
||||||
|
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
|
||||||
secret.DebugWith("Getting secret from vault",
|
secret.DebugWith("Getting secret from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
@@ -326,7 +327,10 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
|
|||||||
|
|
||||||
// GetSecretVersion retrieves a specific version of a secret. The version
|
// GetSecretVersion retrieves a specific version of a secret. The version
|
||||||
// must be one of the secret's versions; GetSecret gets the current one.
|
// must be one of the secret's versions; GetSecret gets the current one.
|
||||||
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
// The caller must destroy the returned buffer.
|
||||||
|
func (v *Vault) GetSecretVersion(
|
||||||
|
name string, version string,
|
||||||
|
) (*memguard.LockedBuffer, error) {
|
||||||
secret.DebugWith("Getting secret version from vault",
|
secret.DebugWith("Getting secret version from vault",
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
@@ -372,26 +376,14 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
|
|||||||
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
return nil, fmt.Errorf("failed to decrypt version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a copy to return since the buffer will be destroyed
|
|
||||||
result := make([]byte, decryptedValue.Size())
|
|
||||||
copy(result, decryptedValue.Bytes())
|
|
||||||
decryptedValue.Destroy()
|
|
||||||
|
|
||||||
secret.DebugWith("Successfully decrypted secret version",
|
secret.DebugWith("Successfully decrypted secret version",
|
||||||
slog.String("secret_name", name),
|
slog.String("secret_name", name),
|
||||||
slog.String("version", version),
|
slog.String("version", version),
|
||||||
slog.String("vault_name", v.Name),
|
slog.String("vault_name", v.Name),
|
||||||
slog.Int("decrypted_length", len(result)),
|
slog.Int("decrypted_length", decryptedValue.Size()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Debug: Log metadata about the decrypted value without exposing the actual secret
|
return decryptedValue, nil
|
||||||
secret.Debug("Vault secret decryption debug info",
|
|
||||||
"secret_name", name,
|
|
||||||
"version", version,
|
|
||||||
"decrypted_value_length", len(result),
|
|
||||||
"is_empty", len(result) == 0)
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnlockVault unlocks the vault and returns the long-term private key
|
// UnlockVault unlocks the vault and returns the long-term private key
|
||||||
|
|||||||
@@ -41,14 +41,6 @@ import (
|
|||||||
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon " +
|
||||||
"abandon abandon abandon abandon about"
|
"abandon abandon abandon abandon about"
|
||||||
|
|
||||||
// envTestMnemonic is the (deliberately different) mnemonic placed in the
|
|
||||||
// environment; the vault is unlocked manually with the derived key in
|
|
||||||
// createTestVaultWithKey.
|
|
||||||
//
|
|
||||||
//nolint:dupword // BIP39-style test mnemonic intentionally repeats a word
|
|
||||||
const envTestMnemonic = "abandon abandon abandon abandon abandon abandon " +
|
|
||||||
"abandon abandon abandon about"
|
|
||||||
|
|
||||||
// Shared fixtures for white-box tests in this package.
|
// Shared fixtures for white-box tests in this package.
|
||||||
const (
|
const (
|
||||||
testStateDir = "/test/state"
|
testStateDir = "/test/state"
|
||||||
@@ -73,11 +65,8 @@ func addTestSecretToVault(
|
|||||||
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Set mnemonic for testing
|
// Create vault without a long-term key, which is set up below
|
||||||
t.Setenv(secret.EnvMnemonic, envTestMnemonic)
|
vault, err := CreateVault(fs, testStateDir, "test", nil, nil)
|
||||||
|
|
||||||
// Create vault
|
|
||||||
vault, err := CreateVault(fs, testStateDir, "test")
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Derive and store long-term key from mnemonic
|
// Derive and store long-term key from mnemonic
|
||||||
@@ -98,8 +87,9 @@ func createTestVaultWithKey(t *testing.T, fs afero.Fs) *Vault {
|
|||||||
return vault
|
return vault
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -131,11 +121,15 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
|
|||||||
// Get the secret value
|
// Get the secret value
|
||||||
retrievedValue, err := vault.GetSecret(testSecretPath)
|
retrievedValue, err := vault.GetSecret(testSecretPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, expectedValue, retrievedValue)
|
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, expectedValue, retrievedValue.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -149,8 +143,7 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|||||||
defer failBuffer.Destroy()
|
defer failBuffer.Destroy()
|
||||||
|
|
||||||
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
err := vault.AddSecret(testSecretPath, failBuffer, false)
|
||||||
require.Error(t, err)
|
require.ErrorIs(t, err, ErrSecretExists)
|
||||||
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)
|
||||||
@@ -165,11 +158,15 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
|
|||||||
// Current value should be version-2
|
// Current value should be version-2
|
||||||
value, err := vault.GetSecret(testSecretPath)
|
value, err := vault.GetSecret(testSecretPath)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2"), value)
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-2"), value.Bytes())
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVaultGetSecretVersion(t *testing.T) {
|
func TestVaultGetSecretVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -192,15 +189,21 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
|||||||
|
|
||||||
// Get specific version (first one)
|
// Get specific version (first one)
|
||||||
firstVersion := versions[1] // Last in list is first created
|
firstVersion := versions[1] // Last in list is first created
|
||||||
value, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-1"), value)
|
|
||||||
|
defer first.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-1"), first.Bytes())
|
||||||
|
|
||||||
// Get specific version (second one)
|
// Get specific version (second one)
|
||||||
secondVersion := versions[0] // First in list is most recent
|
secondVersion := versions[0] // First in list is most recent
|
||||||
value, err = vault.GetSecretVersion(testSecretPath, secondVersion)
|
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, []byte("version-2"), value)
|
|
||||||
|
defer second.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, []byte("version-2"), second.Bytes())
|
||||||
|
|
||||||
// An empty version is not one of the versions; GetSecret gets the
|
// An empty version is not one of the versions; GetSecret gets the
|
||||||
// current one
|
// current one
|
||||||
@@ -208,8 +211,9 @@ func TestVaultGetSecretVersion(t *testing.T) {
|
|||||||
require.ErrorIs(t, err, ErrVersionNotFound)
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVaultVersionTimestamps(t *testing.T) {
|
func TestVaultVersionTimestamps(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -291,8 +295,9 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
@@ -303,12 +308,12 @@ 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.Error(t, err)
|
require.ErrorIs(t, err, ErrVersionNotFound)
|
||||||
assert.Contains(t, err.Error(), "not found")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
|
|
||||||
func TestUpdateVersionMetadata(t *testing.T) {
|
func TestUpdateVersionMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault with long-term key
|
// Create vault with long-term key
|
||||||
|
|||||||
+206
-117
@@ -2,8 +2,10 @@ package vault
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -70,7 +72,9 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
secret.Debug("Creating passphrase unlocker instance",
|
secret.Debug("Creating passphrase unlocker instance",
|
||||||
"unlocker_type", metadata.Type)
|
"unlocker_type", metadata.Type)
|
||||||
|
|
||||||
unlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||||
|
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
||||||
|
unlocker = passphraseUnlocker
|
||||||
case "pgp":
|
case "pgp":
|
||||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||||
|
|
||||||
@@ -101,7 +105,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
|
|
||||||
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
// resolveUnlockerDirectory reads the current-unlocker file to get the
|
||||||
// unlocker directory path
|
// unlocker directory path
|
||||||
// The file contains just the unlocker name (e.g., "passphrase")
|
// The file contains just the name of the unlocker's directory in unlockers.d
|
||||||
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, error) {
|
||||||
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
secret.Debug("Reading current-unlocker file", "path", currentUnlockerPath)
|
||||||
|
|
||||||
@@ -126,7 +130,11 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||||
// instance and its directory path
|
// instance and its directory path. A directory that ListUnlockers skips is
|
||||||
|
// skipped here too, with the same warning. Such a directory has no ID: if
|
||||||
|
// no unlocker has the ID unlockerID but such a directory is named
|
||||||
|
// unlockerID, that directory is returned with a nil unlocker, so that
|
||||||
|
// RemoveUnlocker can remove it.
|
||||||
//
|
//
|
||||||
//nolint:ireturn // returns one of several concrete unlocker implementations
|
//nolint:ireturn // returns one of several concrete unlocker implementations
|
||||||
func (v *Vault) findUnlockerByID(
|
func (v *Vault) findUnlockerByID(
|
||||||
@@ -137,42 +145,24 @@ func (v *Vault) findUnlockerByID(
|
|||||||
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
return nil, "", fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
skippedDirPath := ""
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if !file.IsDir() {
|
if !file.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read metadata file
|
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
||||||
metadataPath := filepath.Join(unlockersDir, file.Name(), "unlocker-metadata.json")
|
|
||||||
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
||||||
if err != nil {
|
if !ok {
|
||||||
return nil, "", fmt.Errorf(
|
if file.Name() == unlockerID {
|
||||||
"failed to check if metadata exists for unlocker %s: %w",
|
skippedDirPath = unlockerDirPath
|
||||||
file.Name(), err)
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
// Skip directories without metadata - they might not be unlockers
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"failed to read metadata for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, "", fmt.Errorf(
|
|
||||||
"failed to parse metadata for unlocker %s: %w", file.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockerDirPath := filepath.Join(unlockersDir, file.Name())
|
|
||||||
|
|
||||||
// Create the appropriate unlocker instance
|
// Create the appropriate unlocker instance
|
||||||
var tempUnlocker secret.Unlocker
|
var tempUnlocker secret.Unlocker
|
||||||
|
|
||||||
@@ -195,11 +185,12 @@ func (v *Vault) findUnlockerByID(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, "", nil
|
return nil, skippedDirPath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUnlockers returns a list of available unlockers for this vault
|
// ListUnlockers returns the metadata of each unlocker of this vault, keyed
|
||||||
func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
// by the unlocker's ID, the name of its directory in unlockers.d
|
||||||
|
func (v *Vault) ListUnlockers() (map[string]UnlockerMetadata, error) {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -214,7 +205,7 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return []UnlockerMetadata{}, nil
|
return map[string]UnlockerMetadata{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List directories in unlockers.d
|
// List directories in unlockers.d
|
||||||
@@ -223,55 +214,88 @@ func (v *Vault) ListUnlockers() ([]UnlockerMetadata, error) {
|
|||||||
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var unlockers []UnlockerMetadata
|
unlockers := map[string]UnlockerMetadata{}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
if file.IsDir() {
|
if !file.IsDir() {
|
||||||
// Read metadata file
|
continue
|
||||||
metadataPath := filepath.Join(unlockersDir, file.Name(),
|
}
|
||||||
"unlocker-metadata.json")
|
|
||||||
|
|
||||||
exists, err := afero.Exists(v.fs, metadataPath)
|
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
||||||
if err != nil {
|
if ok {
|
||||||
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
|
unlockers[file.Name()] = metadata
|
||||||
"directory", file.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
secret.Warn("Skipping unlocker directory with missing metadata file",
|
|
||||||
"directory", file.Name())
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory with unreadable metadata file",
|
|
||||||
"directory", file.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var metadata UnlockerMetadata
|
|
||||||
|
|
||||||
err = json.Unmarshal(metadataBytes, &metadata)
|
|
||||||
if err != nil {
|
|
||||||
secret.Warn("Skipping unlocker directory with corrupt metadata file",
|
|
||||||
"directory", file.Name(), "error", err)
|
|
||||||
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockers = append(unlockers, metadata)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return unlockers, nil
|
return unlockers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveUnlocker removes an unlocker from this vault
|
// readUnlockerMetadataOrWarn reads the metadata of the unlocker directory
|
||||||
|
// name in unlockersDir. If the metadata file cannot be checked for, is
|
||||||
|
// missing, or cannot be read or parsed, it warns, naming the directory,
|
||||||
|
// and returns false: the caller skips that directory.
|
||||||
|
func (v *Vault) readUnlockerMetadataOrWarn(
|
||||||
|
unlockersDir, name string,
|
||||||
|
) (UnlockerMetadata, bool) {
|
||||||
|
metadataPath := filepath.Join(unlockersDir, name, "unlocker-metadata.json")
|
||||||
|
|
||||||
|
var metadata UnlockerMetadata
|
||||||
|
|
||||||
|
exists, err := afero.Exists(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Skipping unlocker directory whose metadata file cannot be checked",
|
||||||
|
"directory", name, "error", err)
|
||||||
|
|
||||||
|
return metadata, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
secret.Warn("Skipping unlocker directory with missing metadata file",
|
||||||
|
"directory", name)
|
||||||
|
|
||||||
|
return metadata, false
|
||||||
|
}
|
||||||
|
|
||||||
|
metadataBytes, err := afero.ReadFile(v.fs, metadataPath)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Skipping unlocker directory with unreadable metadata file",
|
||||||
|
"directory", name, "error", err)
|
||||||
|
|
||||||
|
return metadata, false
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
if err != nil {
|
||||||
|
secret.Warn("Skipping unlocker directory with corrupt metadata file",
|
||||||
|
"directory", name, "error", err)
|
||||||
|
|
||||||
|
return metadata, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasUnlocker reports whether RemoveUnlocker finds something to remove by
|
||||||
|
// the ID unlockerID: an unlocker with that ID, or an unlocker directory of
|
||||||
|
// that name that ListUnlockers skips.
|
||||||
|
func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
|
||||||
|
vaultDir, err := v.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, unlockerDir, err := v.findUnlockerByID(
|
||||||
|
filepath.Join(vaultDir, "unlockers.d"), unlockerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlockerDir != "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
||||||
|
// directory that ListUnlockers skips is removed by its directory name; its
|
||||||
|
// type is unknown, so only the directory is removed.
|
||||||
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||||
vaultDir, err := v.GetDirectory()
|
vaultDir, err := v.GetDirectory()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -282,15 +306,19 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
unlocker, _, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if unlocker == nil {
|
if unlockerDir == "" {
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if unlocker == nil {
|
||||||
|
return secret.RemoveDirAtomic(v.fs, unlockerDir)
|
||||||
|
}
|
||||||
|
|
||||||
// Use the unlocker's Remove method
|
// Use the unlocker's Remove method
|
||||||
return unlocker.Remove()
|
return unlocker.Remove()
|
||||||
}
|
}
|
||||||
@@ -306,12 +334,14 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
|
|
||||||
// Find the unlocker by ID
|
// Find the unlocker by ID
|
||||||
_, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
unlocker, targetUnlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if targetUnlockerDir == "" {
|
// A directory found without an unlocker is one ListUnlockers skips; it
|
||||||
|
// cannot be selected.
|
||||||
|
if unlocker == nil {
|
||||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,7 +362,10 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
|
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker in a
|
||||||
|
// directory of its own, makes it the current unlocker, and only then removes
|
||||||
|
// the vault's other passphrase unlockers: a vault keeps one. A crash at any
|
||||||
|
// point leaves a complete current unlocker, the old one or the new.
|
||||||
// The passphrase must be provided as a LockedBuffer for security
|
// The passphrase must be provided as a LockedBuffer for security
|
||||||
func (v *Vault) CreatePassphraseUnlocker(
|
func (v *Vault) CreatePassphraseUnlocker(
|
||||||
passphrase *memguard.LockedBuffer,
|
passphrase *memguard.LockedBuffer,
|
||||||
@@ -344,34 +377,55 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
|
|
||||||
// We need to get the long-term key (either from memory if unlocked, or
|
// We need to get the long-term key (either from memory if unlocked, or
|
||||||
// derive it). Getting it before anything is written means failing to
|
// derive it). Getting it before anything is written means failing to
|
||||||
// get it changes nothing, even when replacing the current unlocker.
|
// get it changes nothing.
|
||||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
return nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create unlocker directory
|
unlockersDir := filepath.Join(vaultDir, "unlockers.d")
|
||||||
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase)
|
|
||||||
|
|
||||||
err = v.fs.MkdirAll(unlockerDir, secret.DirPerms)
|
// The passphrase unlockers the new one replaces
|
||||||
|
oldDirs, err := v.passphraseUnlockerDirs(unlockersDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
unlocker, err := writePassphraseUnlocker(v.fs, vaultDir, ltIdentity, passphrase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, oldDir := range oldDirs {
|
||||||
|
err = secret.RemoveDirAtomic(v.fs, oldDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"created and selected the new passphrase unlocker: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unlocker, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePassphraseUnlocker writes a new passphrase unlocker of the long-term
|
||||||
|
// key ltIdentity into the vault directory vaultDir, in a directory of its own,
|
||||||
|
// and makes it the vault's current unlocker.
|
||||||
|
func writePassphraseUnlocker(
|
||||||
|
fs afero.Fs, vaultDir string, ltIdentity *age.X25519Identity,
|
||||||
|
passphrase *memguard.LockedBuffer,
|
||||||
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
|
createdAt := time.Now()
|
||||||
|
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerTypePassphrase+"-"+
|
||||||
|
createdAt.UTC().Format(secret.UnlockerTimeFormat))
|
||||||
|
|
||||||
// Generate new age keypair for unlocker
|
// Generate new age keypair for unlocker
|
||||||
unlockerIdentity, err := age.GenerateX25519Identity()
|
unlockerIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
return nil, fmt.Errorf("failed to generate unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write the unlocker keypair (public and passphrase-encrypted private)
|
|
||||||
err = v.writeUnlockerKeypair(unlockerDir, unlockerIdentity, passphrase)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Encrypt long-term private key to this unlocker
|
// Encrypt long-term private key to this unlocker
|
||||||
ltPrivKeyBuffer := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
ltPrivKeyBuffer := secret.IdentityToLockedBuffer(ltIdentity)
|
||||||
defer ltPrivKeyBuffer.Destroy()
|
defer ltPrivKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
|
encryptedLtPrivKey, err := secret.EncryptToRecipient(ltPrivKeyBuffer,
|
||||||
@@ -380,18 +434,9 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
return nil, fmt.Errorf("failed to encrypt long-term private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
|
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, ltPrivKeyPath, encryptedLtPrivKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write the metadata last: readers skip an unlocker directory without
|
|
||||||
// it, so an unlocker interrupted before this point is never used.
|
|
||||||
metadata := UnlockerMetadata{
|
metadata := UnlockerMetadata{
|
||||||
Type: unlockerTypePassphrase,
|
Type: unlockerTypePassphrase,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: createdAt,
|
||||||
Flags: []string{},
|
Flags: []string{},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,23 +445,54 @@ func (v *Vault) CreatePassphraseUnlocker(
|
|||||||
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
return nil, fmt.Errorf("failed to marshal metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(unlockerDir, "unlocker-metadata.json")
|
// Write the unlocker's files, the metadata last
|
||||||
|
err = secret.WriteDir(fs, unlockerDir, func(dir string) error {
|
||||||
err = secret.WriteFileAtomic(v.fs, metadataPath, metadataBytes)
|
return writeUnlockerFiles(fs, dir, unlockerIdentity, passphrase,
|
||||||
|
encryptedLtPrivKey, metadataBytes)
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the unlocker instance
|
// Make the new unlocker the current one
|
||||||
unlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||||
|
|
||||||
// Select this unlocker as current
|
err = secret.WriteFileAtomic(fs, currentUnlockerPath,
|
||||||
err = v.SelectUnlocker(unlocker.GetID())
|
[]byte(filepath.Base(unlockerDir)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
return nil, fmt.Errorf("failed to select new unlocker: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return unlocker, nil
|
return secret.NewPassphraseUnlocker(fs, unlockerDir, metadata), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// passphraseUnlockerDirs returns the directories in unlockersDir that hold
|
||||||
|
// passphrase unlockers. A directory ListUnlockers skips is left out, with the
|
||||||
|
// same warning.
|
||||||
|
func (v *Vault) passphraseUnlockerDirs(unlockersDir string) ([]string, error) {
|
||||||
|
files, err := afero.ReadDir(v.fs, unlockersDir)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read unlockers directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dirs []string
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata, ok := v.readUnlockerMetadataOrWarn(unlockersDir, file.Name())
|
||||||
|
if ok && metadata.Type == unlockerTypePassphrase {
|
||||||
|
dirs = append(dirs, filepath.Join(unlockersDir, file.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dirs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in
|
// readUnlockerMetadata reads and parses the unlocker-metadata.json file in
|
||||||
@@ -450,26 +526,27 @@ func (v *Vault) readUnlockerMetadata(unlockerDir string) (UnlockerMetadata, erro
|
|||||||
return metadata, nil
|
return metadata, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeUnlockerKeypair writes the unlocker's public key and its
|
// writeUnlockerFiles writes the files of a passphrase unlocker into
|
||||||
// passphrase-encrypted private key into the unlocker directory.
|
// unlockerDir: its public key, its passphrase-encrypted private key, the
|
||||||
func (v *Vault) writeUnlockerKeypair(
|
// long-term private key encrypted to it, and its metadata, last.
|
||||||
|
func writeUnlockerFiles(
|
||||||
|
fs afero.Fs,
|
||||||
unlockerDir string,
|
unlockerDir string,
|
||||||
unlockerIdentity *age.X25519Identity,
|
unlockerIdentity *age.X25519Identity,
|
||||||
passphrase *memguard.LockedBuffer,
|
passphrase *memguard.LockedBuffer,
|
||||||
|
encryptedLtPrivKey, metadataBytes []byte,
|
||||||
) error {
|
) error {
|
||||||
// Write public key
|
// Write public key
|
||||||
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
pubKeyPath := filepath.Join(unlockerDir, "pub.age")
|
||||||
|
|
||||||
err := secret.WriteFileAtomic(v.fs, pubKeyPath,
|
err := secret.WriteFileAtomic(fs, pubKeyPath,
|
||||||
[]byte(unlockerIdentity.Recipient().String()))
|
[]byte(unlockerIdentity.Recipient().String()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
return fmt.Errorf("failed to write unlocker public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Encrypt private key with passphrase
|
// Encrypt private key with passphrase
|
||||||
privKeyStr := unlockerIdentity.String()
|
privKeyBuffer := secret.IdentityToLockedBuffer(unlockerIdentity)
|
||||||
|
|
||||||
privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
|
|
||||||
defer privKeyBuffer.Destroy()
|
defer privKeyBuffer.Destroy()
|
||||||
|
|
||||||
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer, passphrase)
|
||||||
@@ -480,10 +557,22 @@ func (v *Vault) writeUnlockerKeypair(
|
|||||||
// Write encrypted private key
|
// Write encrypted private key
|
||||||
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
privKeyPath := filepath.Join(unlockerDir, "priv.age")
|
||||||
|
|
||||||
err = secret.WriteFileAtomic(v.fs, privKeyPath, encryptedPrivKey)
|
err = secret.WriteFileAtomic(fs, privKeyPath, encryptedPrivKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
return fmt.Errorf("failed to write encrypted unlocker private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = secret.WriteFileAtomic(fs,
|
||||||
|
filepath.Join(unlockerDir, "longterm.age"), encryptedLtPrivKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = secret.WriteFileAtomic(fs,
|
||||||
|
filepath.Join(unlockerDir, "unlocker-metadata.json"), metadataBytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-11
@@ -1,14 +1,15 @@
|
|||||||
package vault
|
package vault
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/internal/secret"
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +19,13 @@ type Vault struct {
|
|||||||
fs afero.Fs
|
fs afero.Fs
|
||||||
stateDir string
|
stateDir string
|
||||||
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
longTermKey *age.X25519Identity // In-memory long-term key when unlocked
|
||||||
|
// Mnemonic, when not nil, is what the long-term key is derived from
|
||||||
|
// instead of the current unlocker. The caller destroys it.
|
||||||
|
Mnemonic *memguard.LockedBuffer
|
||||||
|
// UnlockPassphrase, when not nil, is given to the current unlocker
|
||||||
|
// when that is a passphrase unlocker, which otherwise prompts for it.
|
||||||
|
// The caller destroys it.
|
||||||
|
UnlockPassphrase *memguard.LockedBuffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewVault creates a new Vault instance
|
// NewVault creates a new Vault instance
|
||||||
@@ -56,6 +64,18 @@ func (v *Vault) ClearLongTermKey() {
|
|||||||
v.longTermKey = nil
|
v.longTermKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMnemonic sets v.Mnemonic, for code that has v only as a
|
||||||
|
// secret.VaultInterface.
|
||||||
|
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||||
|
v.Mnemonic = mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
|
||||||
|
// a secret.VaultInterface.
|
||||||
|
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
|
||||||
|
v.UnlockPassphrase = passphrase
|
||||||
|
}
|
||||||
|
|
||||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
||||||
// from available sources
|
// from available sources
|
||||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
@@ -66,9 +86,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
|
|
||||||
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
secret.Debug("Vault is locked, attempting to unlock", "vault_name", v.Name)
|
||||||
|
|
||||||
// Try to derive from environment mnemonic first
|
if v.Mnemonic != nil {
|
||||||
if envMnemonic := os.Getenv(secret.EnvMnemonic); envMnemonic != "" {
|
return v.deriveLongTermKeyFromMnemonic(v.Mnemonic.String())
|
||||||
return v.deriveLongTermKeyFromMnemonic(envMnemonic)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No mnemonic available, try to use current unlocker
|
// No mnemonic available, try to use current unlocker
|
||||||
@@ -80,7 +99,8 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
|
secret.Debug("Failed to get current unlocker", "error", err, "vault_name", v.Name)
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
|
return nil, v.withMnemonicAdvice(
|
||||||
|
fmt.Errorf("failed to get current unlocker: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Retrieved current unlocker for vault unlock",
|
secret.DebugWith("Retrieved current unlocker for vault unlock",
|
||||||
@@ -94,7 +114,7 @@ func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
|||||||
// Other unlockers return their own identity, used to decrypt longterm.age.
|
// Other unlockers return their own identity, used to decrypt longterm.age.
|
||||||
ltIdentity, err := v.unlockLongTermKey(unlocker)
|
ltIdentity, err := v.unlockLongTermKey(unlocker)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, v.withMnemonicAdvice(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.DebugWith("Successfully obtained long-term identity via unlocker",
|
secret.DebugWith("Successfully obtained long-term identity via unlocker",
|
||||||
@@ -138,7 +158,12 @@ func (v *Vault) NumSecrets() (int, error) {
|
|||||||
|
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|
||||||
exists, _ := afero.DirExists(v.fs, secretsDir)
|
exists, err := afero.DirExists(v.fs, secretsDir)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to check secrets directory %s: %w",
|
||||||
|
secretsDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
return 0, nil
|
return 0, nil
|
||||||
}
|
}
|
||||||
@@ -162,7 +187,7 @@ func (v *Vault) NumSecrets() (int, error) {
|
|||||||
|
|
||||||
exists, err := afero.Exists(v.fs, currentFile)
|
exists, err := afero.Exists(v.fs, currentFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Skip directories we can't read
|
return 0, fmt.Errorf("failed to check %s: %w", currentFile, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if exists {
|
if exists {
|
||||||
@@ -176,9 +201,9 @@ func (v *Vault) NumSecrets() (int, error) {
|
|||||||
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
|
// deriveLongTermKeyFromMnemonic derives the long-term key from the given
|
||||||
// mnemonic, verifies it against the vault metadata, and caches it in memory.
|
// mnemonic, verifies it against the vault metadata, and caches it in memory.
|
||||||
func (v *Vault) deriveLongTermKeyFromMnemonic(
|
func (v *Vault) deriveLongTermKeyFromMnemonic(
|
||||||
envMnemonic string,
|
mnemonic string,
|
||||||
) (*age.X25519Identity, error) {
|
) (*age.X25519Identity, error) {
|
||||||
secret.Debug("Using mnemonic from environment for long-term key derivation",
|
secret.Debug("Using mnemonic for long-term key derivation",
|
||||||
"vault_name", v.Name)
|
"vault_name", v.Name)
|
||||||
|
|
||||||
// Load vault metadata to get the derivation index
|
// Load vault metadata to get the derivation index
|
||||||
@@ -194,7 +219,7 @@ func (v *Vault) deriveLongTermKeyFromMnemonic(
|
|||||||
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
return nil, fmt.Errorf("failed to load vault metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
secret.Debug("Failed to derive long-term key from mnemonic",
|
secret.Debug("Failed to derive long-term key from mnemonic",
|
||||||
"error", err, "vault_name", v.Name)
|
"error", err, "vault_name", v.Name)
|
||||||
@@ -272,3 +297,36 @@ func (v *Vault) unlockLongTermKey(
|
|||||||
|
|
||||||
return ltIdentity, nil
|
return ltIdentity, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withMnemonicAdvice returns err, a failure to get the long-term key through
|
||||||
|
// the current unlocker, with advice added: that the mnemonic still opens the
|
||||||
|
// vault, and how to give it a new unlocker. The advice is added only when the
|
||||||
|
// vault metadata records the key that the mnemonic derives; a vault created
|
||||||
|
// without a mnemonic records none, and without its metadata the key cannot
|
||||||
|
// be derived. It is not added when the passphrase could not be read: the
|
||||||
|
// unlocker was not tried, and adding one would need a passphrase read the
|
||||||
|
// same way.
|
||||||
|
func (v *Vault) withMnemonicAdvice(err error) error {
|
||||||
|
if errors.Is(err, secret.ErrPassphraseNotRead) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultDir, _ := v.GetDirectory()
|
||||||
|
|
||||||
|
metadata, metadataErr := LoadVaultMetadata(v.fs, vaultDir)
|
||||||
|
if metadataErr != nil || metadata.PublicKeyHash == "" {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'secret unlocker add' acts on the current vault only.
|
||||||
|
steps := "'secret unlocker add passphrase'"
|
||||||
|
|
||||||
|
current, currentErr := GetCurrentVault(v.fs, v.stateDir)
|
||||||
|
if currentErr != nil || current.Name != v.Name {
|
||||||
|
steps = fmt.Sprintf("'secret vault select %s', then %s", v.Name, steps)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
|
||||||
|
"%s with %s set to the mnemonic to give it a new unlocker",
|
||||||
|
err, v.Name, steps, secret.EnvMnemonic)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -36,8 +37,13 @@ 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")
|
|
||||||
assert.Contains(t, err.Error(), "failed to read long-term public key")
|
var cause *os.PathError
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package vault_test
|
package vault_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -26,12 +28,19 @@ const (
|
|||||||
testPassphrase = "test-passphrase"
|
testPassphrase = "test-passphrase"
|
||||||
)
|
)
|
||||||
|
|
||||||
//nolint:paralleltest // t.Setenv and order-dependent subtests forbid parallel
|
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
|
||||||
func TestVaultOperations(t *testing.T) {
|
// destroyed when the test ends.
|
||||||
// Test environment will be cleaned up automatically by t.Setenv
|
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
t.Helper()
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
|
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||||
|
t.Cleanup(mnemonic.Destroy)
|
||||||
|
|
||||||
|
return mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
|
//nolint:paralleltest // order-dependent subtests forbid parallel
|
||||||
|
func TestVaultOperations(t *testing.T) {
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
@@ -63,7 +72,8 @@ func TestVaultOperations(t *testing.T) {
|
|||||||
func testCreateVault(t *testing.T, fs afero.Fs) {
|
func testCreateVault(t *testing.T, fs afero.Fs) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -184,10 +194,11 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to get secret: %v", err)
|
t.Fatalf("Failed to get secret: %v", err)
|
||||||
}
|
}
|
||||||
|
defer retrievedValue.Destroy()
|
||||||
|
|
||||||
if string(retrievedValue) != string(expectedValue) {
|
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
|
||||||
t.Errorf("Expected secret value '%s', got '%s'",
|
t.Errorf("Expected secret value '%s', got '%s'",
|
||||||
string(expectedValue), string(retrievedValue))
|
expectedValue, retrievedValue.Bytes())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,6 +230,8 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Test vault unlocking (should happen automatically via mnemonic)
|
// Test vault unlocking (should happen automatically via mnemonic)
|
||||||
|
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||||
|
|
||||||
if vlt.Locked() {
|
if vlt.Locked() {
|
||||||
_, err := vlt.UnlockVault()
|
_, err := vlt.UnlockVault()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -279,15 +292,14 @@ func testUnlockerOperations(t *testing.T, fs afero.Fs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
||||||
// Set test environment variables
|
t.Parallel()
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
// Use in-memory filesystem
|
// Use in-memory filesystem
|
||||||
fs := afero.NewMemMapFs()
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
// Create vault
|
// Create vault
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName)
|
vlt, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||||
|
testMnemonicBuffer(t), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to create vault: %v", err)
|
t.Fatalf("Failed to create vault: %v", err)
|
||||||
}
|
}
|
||||||
@@ -333,3 +345,29 @@ func TestListUnlockers_SkipsMissingMetadata(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateVaultUnlockerNeedsMnemonic checks that CreateVault, given a
|
||||||
|
// passphrase for an unlocker but no mnemonic to derive the long-term key from,
|
||||||
|
// fails without writing anything.
|
||||||
|
func TestCreateVaultUnlockerNeedsMnemonic(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
defer passphrase.Destroy()
|
||||||
|
|
||||||
|
_, err := vault.CreateVault(fs, testStateDir, testVaultName, nil, passphrase)
|
||||||
|
if !errors.Is(err, vault.ErrUnlockerWithoutMnemonic) {
|
||||||
|
t.Fatalf("Expected ErrUnlockerWithoutMnemonic, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := afero.Exists(fs, testStateDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to check for the state directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
t.Errorf("CreateVault wrote the state directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+5
-18
@@ -38,7 +38,6 @@ 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
|
||||||
@@ -330,24 +329,17 @@ func TestClampFunction(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireIdentityError asserts that identity derivation failed with an
|
// requireIdentityError asserts that identity derivation failed with
|
||||||
// error containing errorMsg and returned no identity.
|
// errInvalidScalarSize 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 err == nil {
|
if !errors.Is(err, errInvalidScalarSize) {
|
||||||
t.Errorf("expected error but got none")
|
t.Errorf("expected errInvalidScalarSize, got %v", err)
|
||||||
} else if !strings.Contains(err.Error(), errorMsg) {
|
|
||||||
t.Errorf(
|
|
||||||
"expected error containing %q, got %q",
|
|
||||||
errorMsg,
|
|
||||||
err.Error(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if identity != nil {
|
if identity != nil {
|
||||||
@@ -363,31 +355,26 @@ 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",
|
||||||
@@ -419,7 +406,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, tt.errorMsg)
|
requireIdentityError(t, identity, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-26
@@ -59,16 +59,6 @@ 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
|
||||||
@@ -381,14 +371,8 @@ func DeriveBase64Password(
|
|||||||
// Remove any padding
|
// Remove any padding
|
||||||
encodedStr = strings.TrimRight(encodedStr, "=")
|
encodedStr = strings.TrimRight(encodedStr, "=")
|
||||||
|
|
||||||
// Slice to the desired password length
|
// Slice to the desired password length: 64 bytes of entropy leave 86
|
||||||
if len(encodedStr) < int(pwdLen) {
|
// characters, the most pwdLen allows
|
||||||
return "", fmt.Errorf(
|
|
||||||
"derived password length %d %w %d",
|
|
||||||
len(encodedStr), ErrPasswordTooShort, pwdLen,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return encodedStr[:pwdLen], nil
|
return encodedStr[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,14 +395,8 @@ 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
|
// Slice to the desired password length: 64 bytes of entropy give 80
|
||||||
if len(encoded) < int(pwdLen) {
|
// characters, the most pwdLen allows
|
||||||
return "", fmt.Errorf(
|
|
||||||
"encoded length %d %w %d",
|
|
||||||
len(encoded), ErrEncodedTooShort, pwdLen,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return encoded[:pwdLen], nil
|
return encoded[:pwdLen], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+35
-9
@@ -4,6 +4,7 @@ package bip85_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -1013,14 +1014,13 @@ 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,6 +1030,7 @@ func TestInvalidParameters(t *testing.T) {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
want: bip85.ErrInvalidWordCount,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Base64 password too short",
|
name: "Base64 password too short",
|
||||||
@@ -1039,6 +1040,7 @@ func TestInvalidParameters(t *testing.T) {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
want: bip85.ErrInvalidBase64PwdLen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Base64 password too long",
|
name: "Base64 password too long",
|
||||||
@@ -1048,6 +1050,7 @@ func TestInvalidParameters(t *testing.T) {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
want: bip85.ErrInvalidBase64PwdLen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Base85 password too short",
|
name: "Base85 password too short",
|
||||||
@@ -1057,6 +1060,7 @@ func TestInvalidParameters(t *testing.T) {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
want: bip85.ErrInvalidBase85PwdLen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Base85 password too long",
|
name: "Base85 password too long",
|
||||||
@@ -1066,6 +1070,7 @@ func TestInvalidParameters(t *testing.T) {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
want: bip85.ErrInvalidBase85PwdLen,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hex data too small",
|
name: "Hex data too small",
|
||||||
@@ -1075,6 +1080,7 @@ func TestInvalidParameters(t *testing.T) {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
},
|
},
|
||||||
|
want: bip85.ErrInvalidNumBytes,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Hex data too large",
|
name: "Hex data too large",
|
||||||
@@ -1084,23 +1090,43 @@ 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 err == nil {
|
if !errors.Is(err, tc.want) {
|
||||||
t.Errorf("Expected error for %s, but got nil", tc.name)
|
t.Errorf("Expected %v for %s, got %v", tc.want, tc.name, err)
|
||||||
} 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()
|
||||||
|
|||||||
+1
-6
@@ -6,6 +6,7 @@
|
|||||||
# make, node, yarn, go, or python). Node is used directly if installed;
|
# make, node, yarn, go, or python). Node is used directly if installed;
|
||||||
# otherwise a pinned version is installed via nvm (installing nvm
|
# otherwise a pinned version is installed via nvm (installing nvm
|
||||||
# itself first, from a hash-verified release archive, never curl | sh).
|
# itself first, from a hash-verified release archive, never curl | sh).
|
||||||
|
# golangci-lint is never installed: script/lint runs it in docker.
|
||||||
#
|
#
|
||||||
# Uncomment the language sections in main() that apply to this repo.
|
# Uncomment the language sections in main() that apply to this repo.
|
||||||
set -eu
|
set -eu
|
||||||
@@ -136,12 +137,6 @@ main() {
|
|||||||
|
|
||||||
# ---- 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
|
||||||
# golangci-lint: packaged in nix, brew, and apk. On apt there is no
|
|
||||||
# package: download a specific release archive from GitHub and
|
|
||||||
# verify its hash (verify_sha256), never curl | sh.
|
|
||||||
if missing golangci-lint; then
|
|
||||||
pkg_install golangci-lint golangci-lint golangci-lint golangci-lint
|
|
||||||
fi
|
|
||||||
go mod download
|
go mod download
|
||||||
|
|
||||||
# ---- Python repos ----
|
# ---- Python repos ----
|
||||||
|
|||||||
Executable
+29
@@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/build: build the `secret` binary into the repo root, with its
|
||||||
|
# version and git commit stamped in (`secret info` shows both).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
# CGO is required (Makefile exports this too)
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
# A VERSION set in the environment wins (`make build VERSION=x`, as
|
||||||
|
# the Dockerfile does); otherwise `git describe` of this checkout.
|
||||||
|
version="${VERSION:-}"
|
||||||
|
if [ -z "$version" ]; then
|
||||||
|
version="$(git describe --tags --always --dirty 2>/dev/null ||
|
||||||
|
echo dev)"
|
||||||
|
fi
|
||||||
|
commit="$(git rev-parse HEAD 2>/dev/null || echo unknown)"
|
||||||
|
pkg=git.eeqj.de/sneak/secret/internal/cli
|
||||||
|
# Build the file, not the package `./cmd/secret`: a package build
|
||||||
|
# also stamps git status into the binary and fails where git cannot
|
||||||
|
# read the checkout, instead of falling back to `dev`/`unknown`.
|
||||||
|
go build -v \
|
||||||
|
-ldflags "-X '$pkg.Version=$version' -X '$pkg.GitCommit=$commit'" \
|
||||||
|
-o secret cmd/secret/main.go
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
+3
-3
@@ -1,7 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/check: run all checks (test, lint, fmt-check). Our own
|
# script/check: run all checks (test, lint, lint-darwin, fmt-check). Our
|
||||||
# extension to scripts-to-rule-them-all. Must not modify any files.
|
# own extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
# Generic: usually needs no adaptation.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -9,6 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
"$SCRIPT_DIR/test"
|
"$SCRIPT_DIR/test"
|
||||||
"$SCRIPT_DIR/lint"
|
"$SCRIPT_DIR/lint"
|
||||||
|
"$SCRIPT_DIR/lint-darwin"
|
||||||
"$SCRIPT_DIR/fmt-check"
|
"$SCRIPT_DIR/fmt-check"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -4,13 +4,17 @@
|
|||||||
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
|
# The Gitea workflow runs this on push. The memlock ulimit lets the tests
|
||||||
# that lock large secrets in memory (memguard mlocks them) run; under the
|
# that lock large secrets in memory (memguard mlocks them) run; under the
|
||||||
# lower limit of a plain `docker build .` they are skipped.
|
# lower limit of a plain `docker build .` they are skipped.
|
||||||
|
# A cached build checks nothing: a new CHECK_EPOCH on every run makes the
|
||||||
|
# Dockerfile's check steps run again on an unchanged tree, while its base
|
||||||
|
# images and module downloads stay cached.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --ulimit memlock=-1:-1 .
|
docker build --ulimit memlock=-1:-1 \
|
||||||
|
--build-arg CHECK_EPOCH="$(date +%s)" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+14
-4
@@ -1,14 +1,24 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/lint: run the linter.
|
# script/lint: run the linter, in docker only. Builds Dockerfile.lint,
|
||||||
|
# where golangci-lint runs as a build step.
|
||||||
|
#
|
||||||
|
# A cached build lints nothing, so --no-cache-filter rebuilds the lint
|
||||||
|
# stage on every run, an unchanged tree included. It ignores a stage name
|
||||||
|
# that does not exist, so --target names the same stage: a rename then
|
||||||
|
# fails the build instead of serving the lint from cache. cacheonly keeps
|
||||||
|
# no image; only the build's success matters.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
# CGO is required (Makefile exports this too)
|
docker build \
|
||||||
export CGO_ENABLED=1
|
--progress=plain \
|
||||||
golangci-lint run --timeout 5m
|
--target lint \
|
||||||
|
--no-cache-filter=lint \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
-f Dockerfile.lint .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/lint-darwin: type-check (go vet) and lint the code as a macOS
|
||||||
|
# build compiles it, from Linux, in docker only. CI runs on Linux, which
|
||||||
|
# never compiles the files built only for macOS. Builds the lint-darwin
|
||||||
|
# stage of Dockerfile.lint, rebuilt on every run as script/lint does.
|
||||||
|
#
|
||||||
|
# Cgo is off: compiling cgo code for macOS needs Apple's SDK headers. That
|
||||||
|
# leaves out the files built only with cgo on macOS: the keychain unlocker's
|
||||||
|
# calls into the keychain (keychainunlocker_cgo.go, and
|
||||||
|
# keychainunlocker_test.go) and the Secure Enclave bindings (internal/macse).
|
||||||
|
# Nothing on Linux checks those.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build \
|
||||||
|
--progress=plain \
|
||||||
|
--target lint-darwin \
|
||||||
|
--no-cache-filter=lint-darwin \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
-f Dockerfile.lint .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in New Issue
Block a user